@nathapp/nax-agent 0.1.0 → 0.3.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 (129) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -2
  3. package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
  4. package/dist/coding-tools/coding-tool-sandbox.js +14 -3
  5. package/dist/coding-tools/coding-tool-support.d.ts +3 -0
  6. package/dist/coding-tools/coding-tool-support.js +2 -0
  7. package/dist/index.d.ts +21 -3
  8. package/dist/index.js +13 -3
  9. package/dist/infra/stderr-tail.d.ts +8 -0
  10. package/dist/infra/stderr-tail.js +48 -0
  11. package/dist/internal/redact.d.ts +23 -0
  12. package/dist/internal/redact.js +60 -0
  13. package/dist/internal.d.ts +7 -0
  14. package/dist/internal.js +8 -0
  15. package/dist/native/adapter-deps.d.ts +3 -3
  16. package/dist/native/adapter-deps.js +3 -3
  17. package/dist/native/client.d.ts +7 -1
  18. package/dist/native/client.js +1 -1
  19. package/dist/native/credentials/change-guard.d.ts +8 -1
  20. package/dist/native/credentials/change-guard.js +1 -1
  21. package/dist/native/credentials/fingerprint.d.ts +4 -1
  22. package/dist/native/credentials/fingerprint.js +6 -3
  23. package/dist/native/credentials/session-source.d.ts +12 -0
  24. package/dist/native/credentials/session-source.js +33 -0
  25. package/dist/native/index.d.ts +3 -1
  26. package/dist/native/index.js +2 -1
  27. package/dist/native/session/loop-events/types.d.ts +1 -1
  28. package/dist/native/session/loop-handlers.d.ts +3 -0
  29. package/dist/native/session/loop-handlers.js +1 -1
  30. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  31. package/dist/native/session/memory-transcript-store.js +36 -0
  32. package/dist/native/session/session.d.ts +138 -96
  33. package/dist/native/session/session.js +107 -127
  34. package/dist/native/session/transcript-identity.d.ts +39 -0
  35. package/dist/native/session/transcript-identity.js +81 -0
  36. package/dist/native/session/transcript-store.d.ts +22 -21
  37. package/dist/native/session/transcript-store.js +59 -68
  38. package/dist/native/session/transcript-types.d.ts +54 -0
  39. package/dist/native/session/transcript-types.js +7 -0
  40. package/dist/native/session/truncation-handler.d.ts +4 -3
  41. package/dist/native/session/truncation-handler.js +7 -10
  42. package/dist/native/session/turn-complete-step.d.ts +4 -1
  43. package/dist/native/session/turn-complete-step.js +11 -4
  44. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  45. package/dist/native/session/turn-event-emitter.js +134 -0
  46. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  47. package/dist/native/session/turn-loop-round-trip.js +8 -4
  48. package/dist/native/session/turn-loop.js +27 -13
  49. package/dist/native/session/turn-result.d.ts +1 -1
  50. package/dist/native/session/turn-result.js +1 -1
  51. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  52. package/dist/native/session/turn-tool-batch.js +14 -6
  53. package/dist/native/session/turn-types.d.ts +17 -1
  54. package/dist/native/session-adapter.d.ts +22 -4
  55. package/dist/native/session-adapter.js +79 -35
  56. package/dist/native/stream-complete.d.ts +17 -0
  57. package/dist/native/stream-complete.js +40 -0
  58. package/dist/permissions/approvals-link.js +5 -4
  59. package/dist/permissions/ask-chain.d.ts +1 -1
  60. package/dist/permissions/ask.d.ts +6 -0
  61. package/dist/permissions/ask.js +6 -0
  62. package/dist/permissions/index.d.ts +1 -1
  63. package/dist/permissions/index.js +1 -1
  64. package/dist/sandbox/policy-builder.d.ts +6 -1
  65. package/dist/sandbox/policy-builder.js +18 -14
  66. package/dist/session/agent-session-deps.d.ts +12 -0
  67. package/dist/session/agent-session-deps.js +28 -0
  68. package/dist/session/agent-session-errors.d.ts +11 -0
  69. package/dist/session/agent-session-errors.js +12 -0
  70. package/dist/session/agent-session-options.d.ts +12 -0
  71. package/dist/session/agent-session-options.js +96 -0
  72. package/dist/session/agent-session-resume.d.ts +14 -0
  73. package/dist/session/agent-session-resume.js +49 -0
  74. package/dist/session/agent-session-turn.d.ts +41 -0
  75. package/dist/session/agent-session-turn.js +177 -0
  76. package/dist/session/agent-session-types.d.ts +182 -0
  77. package/dist/session/agent-session-types.js +1 -0
  78. package/dist/session/agent-session.d.ts +8 -0
  79. package/dist/session/agent-session.js +184 -0
  80. package/dist/session/native-backend-options.d.ts +24 -0
  81. package/dist/session/native-backend-options.js +81 -0
  82. package/dist/session/native-backend.d.ts +4 -0
  83. package/dist/session/native-backend.js +106 -0
  84. package/dist/session/pending-asks.d.ts +24 -0
  85. package/dist/session/pending-asks.js +77 -0
  86. package/dist/session/session-ask-link.d.ts +33 -0
  87. package/dist/session/session-ask-link.js +53 -0
  88. package/dist/session/session-ask-port.d.ts +12 -0
  89. package/dist/session/session-ask-port.js +64 -0
  90. package/dist/session/session-backend.d.ts +79 -0
  91. package/dist/session/session-backend.js +1 -0
  92. package/dist/session/session-event-channel.d.ts +17 -0
  93. package/dist/session/session-event-channel.js +132 -0
  94. package/dist/session/session-interaction.d.ts +20 -0
  95. package/dist/session/session-interaction.js +161 -0
  96. package/dist/session/session-tool-support.d.ts +48 -0
  97. package/dist/session/session-tool-support.js +104 -0
  98. package/dist/session/session-types.d.ts +33 -2
  99. package/dist/session/turn-event.d.ts +66 -0
  100. package/dist/session/turn-event.js +1 -0
  101. package/dist/tools/ask-request.js +3 -1
  102. package/dist/tools/bash-cwd.d.ts +3 -3
  103. package/dist/tools/credential-read-deny.d.ts +22 -0
  104. package/dist/tools/credential-read-deny.js +56 -0
  105. package/dist/tools/glob.js +9 -2
  106. package/dist/tools/grep.js +55 -26
  107. package/dist/tools/owned-paths.d.ts +43 -0
  108. package/dist/tools/owned-paths.js +17 -0
  109. package/dist/tools/package-managers.d.ts +3 -0
  110. package/dist/tools/package-managers.js +7 -3
  111. package/dist/tools/policy-bash-raw.d.ts +3 -0
  112. package/dist/tools/policy-bash-raw.js +49 -62
  113. package/dist/tools/policy-command-branch.d.ts +3 -0
  114. package/dist/tools/policy-command-branch.js +2 -1
  115. package/dist/tools/policy-paths-branch.d.ts +14 -8
  116. package/dist/tools/policy-paths-branch.js +20 -19
  117. package/dist/tools/policy.d.ts +10 -7
  118. package/dist/tools/policy.js +9 -7
  119. package/dist/tools/protected-paths.d.ts +6 -6
  120. package/dist/tools/read.js +38 -20
  121. package/dist/tools/registry.d.ts +3 -0
  122. package/dist/tools/run-command-exec.js +1 -0
  123. package/dist/tools/runtime.d.ts +3 -0
  124. package/dist/tools/runtime.js +1 -0
  125. package/dist/tools/scratchpad.js +2 -1
  126. package/dist/tools/types.d.ts +2 -2
  127. package/package.json +2 -2
  128. package/dist/tools/nax-owned-writes.d.ts +0 -135
  129. package/dist/tools/nax-owned-writes.js +0 -259
@@ -68,6 +68,7 @@
68
68
  */
69
69
  import { resolve } from "node:path";
70
70
  import { normalizeFlagToken } from "./exec-guard.js";
71
+ import { EMPTY_OWNED_PATHS_POLICY } from "./owned-paths.js";
71
72
  import { MANAGER_TABLE } from "./package-managers-table.js";
72
73
  import { resolveWithin } from "./policy.js";
73
74
  /** Package runners that execute an arbitrary, model-named package outside
@@ -295,7 +296,10 @@ function isPathShapedToken(token, manager) {
295
296
  * absolute, so it is `resolveWithin`'s own `isAbsolute` branch that runs the
296
297
  * actual containment check.
297
298
  */
298
- function findPositionalPathConflict(argv, entry, repoRoot, resolvedCwd) {
299
+ function findPositionalPathConflict(argv, entry, repoRoot, resolvedCwd, ownedPaths) {
300
+ // Defaulted HERE, not at the call site: normalizeExec sits at its complexity
301
+ // limit and one more `??` there would breach the ratchet.
302
+ const owned = ownedPaths ?? EMPTY_OWNED_PATHS_POLICY;
299
303
  const manager = normalizeManagerBinary(argv[0]);
300
304
  const verbStart = firstNonFlagIndexAfterBinary(argv);
301
305
  if (verbStart === -1)
@@ -309,7 +313,7 @@ function findPositionalPathConflict(argv, entry, repoRoot, resolvedCwd) {
309
313
  if (!isPathShapedToken(token, manager))
310
314
  continue;
311
315
  const absolute = token.startsWith("/") ? token : resolve(resolvedCwd, token);
312
- if (resolveWithin(repoRoot, absolute) === null) {
316
+ if (resolveWithin(repoRoot, absolute, owned) === null) {
313
317
  return `argv contains a path-shaped argument "${token}" that resolves outside the permitted root`;
314
318
  }
315
319
  }
@@ -381,7 +385,7 @@ export function normalizeExec(input) {
381
385
  const base = effectiveTarget === "package" ? entry.packageForm(argv, input) : entry.rootForm(argv, input);
382
386
  if ("error" in base)
383
387
  return base;
384
- const positionalPathConflict = findPositionalPathConflict(argv, entry, input.repoRoot, base.cwd);
388
+ const positionalPathConflict = findPositionalPathConflict(argv, entry, input.repoRoot, base.cwd, input.ownedPaths);
385
389
  if (positionalPathConflict !== undefined) {
386
390
  return { error: positionalPathConflict };
387
391
  }
@@ -1,3 +1,4 @@
1
+ import type { OwnedPathsPolicy } from "./owned-paths.ts";
1
2
  import type { BashCheck } from "./policy-bash.ts";
2
3
  export interface RawScreenArgs {
3
4
  readonly tool: string;
@@ -15,5 +16,7 @@ export interface RawScreenArgs {
15
16
  * write. Absent or false screens exactly as before.
16
17
  */
17
18
  readonly sandboxWrapped?: boolean;
19
+ /** S3-2: host-owned path rules; decides which tokens and redirects are refused and how. */
20
+ readonly ownedPaths: OwnedPathsPolicy;
18
21
  }
19
22
  export declare function screenRawBashCommand(args: RawScreenArgs): BashCheck;
@@ -6,9 +6,12 @@
6
6
  * rather than refused. That inversion is the whole point of the mode.
7
7
  *
8
8
  * The one thing this screen still does is catch a naive mistake: if the lexer
9
- * CAN parse the command and a segment names or redirects into a path nax owns
10
- * (`.nax/config.json`, `.nax/mono/*\/config.json`, `.nax/features/**\/prd.json`,
11
- * the root queue-control files), the command is denied.
9
+ * CAN parse the command and a segment names or redirects into a path the
10
+ * host's owned-paths policy owns (for nax: `.nax/config.json`,
11
+ * `.nax/mono/*\/config.json`, `.nax/features/**\/prd.json`, the root
12
+ * queue-control files), the command is denied. Which paths those are, and how
13
+ * a refusal reads, is the `OwnedPathsPolicy` port's decision (S3-2); this
14
+ * screen only supplies the per-frame candidates.
12
15
  *
13
16
  * ADVISORY BY CONSTRUCTION. A command using substitution is not parsed and
14
17
  * therefore is not screened at all: `sh -c "$(echo rm) .nax/features/f/prd.json"`
@@ -50,7 +53,6 @@ import { relative, resolve, sep } from "node:path";
50
53
  import { realOrRaw } from "#src/internal/realpath";
51
54
  import { lexBashCommand } from "#src/permissions/index";
52
55
  import { cdTargetsFor, nextWorkingDirectories } from "./bash-cwd.js";
53
- import { isNaxConfigFile, naxOwnedBashRefusal, naxOwnedKind } from "./nax-owned-writes.js";
54
56
  /**
55
57
  * US-002: the start paths that mean "search the whole filesystem". An exact
56
58
  * token comparison, never a prefix one: `~/proj` and `~user` are scoped and
@@ -99,60 +101,40 @@ function deny(reason) {
99
101
  return { kind: "deny", reason, breach: false, escalatable: false };
100
102
  }
101
103
  /**
102
- * A protected path, named for the refusal message, or undefined.
104
+ * One `OwnedBashCandidate` per live frame, in frame order.
103
105
  *
104
- * Returns the KIND it matched alongside the token the agent used, so the caller
105
- * can print kind-specific refusal text (`naxOwnedBashRefusal`).
106
+ * The two passes are the policy's now (`OwnedPathsPolicy.bashRefusal`); this
107
+ * function only supplies the evidence, one candidate per frame.
106
108
  *
107
- * Checked against EVERY frame in `cwd`, mirroring the conservatism of gated
109
+ * Built against EVERY frame in `cwd`, mirroring the conservatism of gated
108
110
  * mode's own `resolveAll`: a `;`-joined `cd` can leave more than one frame
109
111
  * live at once (see `nextWorkingDirectories`), and a candidate that is safe
110
- * from one frame but hits a protected path from another must still deny.
112
+ * from one frame but hits an owned path from another must still be seen.
111
113
  *
112
- * Two passes, in order:
114
+ * Per frame, the candidate carries both halves of today's two-pass order:
113
115
  *
114
- * 1. LEXICAL nax-config check. The raw screen has no typed seam in front of
115
- * it, and `args.resolvePath` (production: `resolveWithin`) returns null
116
- * for `.nax/config.json` exactly because typed tools are SUPPOSED to refuse
117
- * those writes. Falling through to that null would let the entire class
118
- * of nax-config writes go unscreened. Resolve the candidate lexically
119
- * against each frame -- `realOrRaw` walks to the nearest existing ancestor,
120
- * so a not-yet-created file under a symlinked temp root still compares
121
- * equal to `realOrRaw(root)` -- and refuse on `isNaxConfigFile` BEFORE the
122
- * resolver is consulted.
116
+ * 1. `lexical`: the candidate resolved lexically against the frame --
117
+ * `realOrRaw` walks to the nearest existing ancestor, so a not-yet-created
118
+ * file under a symlinked temp root still compares equal to
119
+ * `realOrRaw(root)`. The policy's lexical config check runs
120
+ * on this, BEFORE the resolver is consulted -- the raw screen has no typed
121
+ * seam in front of it, and `resolvePath` (production: `resolveWithin`)
122
+ * returns null for `.nax/config.json` exactly because typed tools are
123
+ * SUPPOSED to refuse those writes.
123
124
  *
124
- * 2. Typed-seam resolver pass (unchanged). `args.resolvePath` continues to be
125
- * the gate for everything else: a `null` skips this frame, an out-of-root
126
- * path skips this frame, and `isNaxOwnedWritePath` covers the queue file
127
- * and feature PRD set.
128
- *
129
- * The two checks do not overlap: pass 1 covers nax config files; pass 2 covers
130
- * feature PRDs and the queue run-control files. `isNaxConfigFile` is checked
131
- * lexically here because the resolver cannot return it, and lexically in
132
- * `resolveWithin` for the same reason; `isNaxOwnedWritePath` is unchanged.
125
+ * 2. `rel`: the typed-seam resolver's root-relative spelling for the same
126
+ * frame, null when it refused or the path is outside the root. The
127
+ * policy's PRD/queue check runs on this.
133
128
  */
134
- function protectedHit(args, candidate, cwd) {
135
- for (const directory of cwd) {
136
- // Pass 1: lexical nax-config check. Independent of the resolver on
137
- // purpose -- see the comment above. `realOrRaw` walks to the nearest
138
- // existing ancestor so the comparison holds even when the file does not
139
- // yet exist on disk (which is the common case: the screen catches the
140
- // write BEFORE the file lands).
141
- const lexical = realOrRaw(resolve(directory, candidate));
142
- if (isNaxConfigFile(args.root, lexical))
143
- return { kind: "config", hit: candidate };
144
- // Pass 2: typed-seam resolver (unchanged).
129
+ function ownedCandidates(args, candidate, cwd) {
130
+ return cwd.map((directory) => {
145
131
  const resolved = args.resolvePath(candidate, directory);
146
- if (resolved === null)
147
- continue;
148
- const rel = relative(args.root, resolved).split(sep).join("/");
149
- if (rel.startsWith(".."))
150
- continue;
151
- const kind = naxOwnedKind(rel);
152
- if (kind !== undefined)
153
- return { kind, hit: candidate };
154
- }
155
- return undefined;
132
+ const rel = resolved === null ? null : relative(args.root, resolved).split(sep).join("/");
133
+ return {
134
+ lexical: realOrRaw(resolve(directory, candidate)),
135
+ rel: rel === null || rel.startsWith("..") ? null : rel,
136
+ };
137
+ });
156
138
  }
157
139
  export function screenRawBashCommand(args) {
158
140
  const { command, tool } = args;
@@ -175,26 +157,31 @@ export function screenRawBashCommand(args) {
175
157
  return deny(`\`find ${findStart}\` searches the whole filesystem and runs into the 300s Bash timeout. ` +
176
158
  `Search within the repository root instead: ${args.root}`);
177
159
  }
160
+ // US-002: sandbox-wrapped, a token that only NAMES a PRD is allowed -- the
161
+ // policy now applies that exemption (`bashRefusal` receives
162
+ // `sandboxWrapped` and skips the PRD read itself). A redirect WRITES, so
163
+ // the policy refuses it even sandbox-wrapped, with the read/write truth.
178
164
  for (const token of segment.tokens) {
179
165
  if (token.opaque)
180
166
  continue;
181
- const hit = protectedHit(args, token.text, cwd);
182
- // US-002: sandbox-wrapped, a token that only NAMES a PRD is allowed --
183
- // the sandbox is the boundary, so a read costs nothing. Config and queue
184
- // are unchanged in both modes.
185
- if (hit !== undefined && !(sandboxWrapped && hit.kind === "prd")) {
186
- return deny(naxOwnedBashRefusal(tool, hit.kind, hit.hit, "names"));
187
- }
167
+ const reason = args.ownedPaths.bashRefusal(tool, token.text, ownedCandidates(args, token.text, cwd), {
168
+ root: args.root,
169
+ verb: "names",
170
+ sandboxWrapped,
171
+ });
172
+ if (reason !== undefined)
173
+ return deny(reason);
188
174
  }
189
175
  for (const redirect of segment.redirects) {
190
176
  if (redirect.opaque)
191
177
  continue;
192
- const hit = protectedHit(args, redirect.target, cwd);
193
- if (hit !== undefined) {
194
- // A redirect WRITES, so even sandbox-wrapped it is refused -- with the
195
- // read/write truth for a PRD (US-002), unchanged text elsewhere.
196
- return deny(naxOwnedBashRefusal(tool, hit.kind, hit.hit, "redirects into", { sandboxWrapped }));
197
- }
178
+ const reason = args.ownedPaths.bashRefusal(tool, redirect.target, ownedCandidates(args, redirect.target, cwd), {
179
+ root: args.root,
180
+ verb: "redirects into",
181
+ sandboxWrapped,
182
+ });
183
+ if (reason !== undefined)
184
+ return deny(reason);
198
185
  }
199
186
  // See the file header ("THE CRITICAL ASYMMETRY"). An unmodelled `cd` is
200
187
  // never itself a denial -- that inversion from gated mode is deliberate --
@@ -10,6 +10,7 @@
10
10
  * reaching `resolveWithin` back would close a cycle.
11
11
  */
12
12
  import type { BashApprovalMode } from "#src/config/bash-approval";
13
+ import type { OwnedPathsPolicy } from "./owned-paths.ts";
13
14
  import type { CompiledEntry } from "./policy-match.ts";
14
15
  import type { PolicyVerdict, ToolScope } from "./types.ts";
15
16
  export interface BashCommandBranchArgs {
@@ -22,6 +23,8 @@ export interface BashCommandBranchArgs {
22
23
  readonly rawBashRefusal?: string;
23
24
  /** US-002: true when the sandbox wraps the command, so `raw` no longer refuses a PRD it only reads. */
24
25
  readonly sandboxWrapped?: boolean;
26
+ /** S3-2: host-owned path rules; the `raw` screen hands it one candidate per live frame. */
27
+ readonly ownedPaths: OwnedPathsPolicy;
25
28
  readonly resolvedRoot: string;
26
29
  readonly denyBy: ReadonlyMap<string, CompiledEntry>;
27
30
  readonly askBy: ReadonlyMap<string, CompiledEntry>;
@@ -12,7 +12,7 @@
12
12
  import { checkBashCommand } from "./policy-bash.js";
13
13
  import { screenRawBashCommand } from "./policy-bash-raw.js";
14
14
  export function commandBranch(args) {
15
- const { tool, scope, input, grant, bashApproval, rawBashRefusal, sandboxWrapped, resolvedRoot, denyBy, askBy, resolvePath, deny, askVerdict, } = args;
15
+ const { tool, scope, input, grant, bashApproval, rawBashRefusal, sandboxWrapped, ownedPaths, resolvedRoot, denyBy, askBy, resolvePath, deny, askVerdict, } = args;
16
16
  if (scope.commandField === undefined)
17
17
  return undefined;
18
18
  if (bashApproval === "raw") {
@@ -25,6 +25,7 @@ export function commandBranch(args) {
25
25
  root: resolvedRoot,
26
26
  sandboxWrapped: sandboxWrapped === true,
27
27
  resolvePath,
28
+ ownedPaths,
28
29
  });
29
30
  if (screened.kind === "deny")
30
31
  return deny(screened.reason, screened.breach, screened.escalatable);
@@ -21,6 +21,7 @@
21
21
  * from it (the collaborators it shares — deny, askVerdict, applyPathRules —
22
22
  * arrive by reference in `PathsBranchContext`, read at call time).
23
23
  */
24
+ import type { OwnedPathsPolicy } from "./owned-paths.ts";
24
25
  import { type CompiledEntry } from "./policy-match.ts";
25
26
  import type { PolicyVerdict, ToolScope } from "./types.ts";
26
27
  /** Mutable scratch shared by `check()`'s branch helpers: first ask rule matched. */
@@ -38,11 +39,14 @@ export interface PathsBranchContext {
38
39
  readonly deny: (reason: string, breach?: boolean, escalatable?: boolean) => PolicyVerdict;
39
40
  readonly askVerdict: (resolvedPaths: readonly string[], rule: string) => PolicyVerdict;
40
41
  readonly applyPathRules: (tool: string, rel: string, state: RuleState) => PolicyVerdict | undefined;
42
+ /** The injected OwnedPathsPolicy (S3-2 port); the owned-config refusal reads through it. */
43
+ readonly ownedPaths: OwnedPathsPolicy;
41
44
  }
42
45
  /**
43
46
  * Absolute, symlink-resolved form of `candidate` if it lies inside `root`,
44
- * is not itself (and does not lie under) `.git/`, and is not one of nax's own
45
- * config files.
47
+ * is not itself (and does not lie under) `.git/`, and is not refused by the
48
+ * injected OwnedPathsPolicy's `configRefusal` (S3-2 port) -- for nax, one of
49
+ * nax's own config files.
46
50
  *
47
51
  * The single containment seam. Multi-root support (a future configurable
48
52
  * extension) changes this function and nothing else, which is why every tool
@@ -54,7 +58,9 @@ export interface PathsBranchContext {
54
58
  * so containment alone never bars it, and an unconditional ("*") grant --
55
59
  * what every non-Exec tool gets under the default `unrestricted` profile --
56
60
  * skips glob matching entirely, so nothing downstream of this function would
57
- * catch it either.
61
+ * catch it either. The owned-config refusal arrives through the port: nax
62
+ * injects `naxOwnedPathsPolicy`, an embedder that injects nothing gets
63
+ * `EMPTY_OWNED_PATHS_POLICY` and only containment plus `.git/` remain.
58
64
  *
59
65
  * Pre-single-frame, this seam carried one exception: an `execTouchedPaths` set
60
66
  * (Task 10) admitted a workspace install's repo-ROOT manifest/lockfile even
@@ -63,7 +69,7 @@ export interface PathsBranchContext {
63
69
  * manifest is in-root by construction and that carve-out was retired
64
70
  * (PR2/Task 13). There is no exception to this seam now.
65
71
  */
66
- export declare function resolveWithin(root: string, candidate: string): string | null;
72
+ export declare function resolveWithin(root: string, candidate: string, owned: OwnedPathsPolicy): string | null;
67
73
  /**
68
74
  * Containment runs before any pattern matching and wins over everything.
69
75
  * Each resolved path is then evaluated deny -> ask -> allow. A verb-only
@@ -74,14 +80,14 @@ export declare function resolveWithin(root: string, candidate: string): string |
74
80
  * `confineTo` (tool-declared, see `ToolScope`) shifts the root passed to
75
81
  * `resolveWithin` from `<root>` to `<root>/<confineTo>`: containment stays
76
82
  * the one seam, and only its ROOT changes. `relativeTo` keeps rooting at
77
- * `resolvedRoot` so grant globs, deny rules and `naxOwnedWriteRefusal`
78
- * continue to see the canonical repo-root-relative spelling -- authors
83
+ * `resolvedRoot` so grant globs, deny rules and the injected policy's
84
+ * `writeRefusal` continue to see the canonical repo-root-relative spelling -- authors
79
85
  * write `.nax/scratchpad/**`, never `**`, regardless of `confineTo`.
80
86
  *
81
87
  * `confineTo` is bound to stay INSIDE `resolvedRoot`: an authoring typo of
82
88
  * `..` or `../shared` would otherwise widen the containment root past the
83
- * policy boundary and re-scope `resolveWithin`'s `.git/`-metadata and
84
- * `isNaxConfigFile` protections to a root that no longer aligns with the
89
+ * policy boundary and re-scope `resolveWithin`'s `.git/`-metadata and the
90
+ * port's `configRefusal` protections to a root that no longer aligns with the
85
91
  * segments those checks assume -- the repo's own `.nax/config.json` would
86
92
  stop being segment-matched against `.nax`. The boundary is the policy
87
93
  root's invariant, so an out-of-root confineTo refuses the call outright
@@ -23,7 +23,6 @@
23
23
  */
24
24
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
25
25
  import { isInside, realOrRaw } from "#src/internal/realpath";
26
- import { isNaxConfigFile } from "./nax-owned-writes.js";
27
26
  import { pathListElements } from "./path-list.js";
28
27
  import { pathFieldValue } from "./policy-input.js";
29
28
  import { matchesAny } from "./policy-match.js";
@@ -51,8 +50,9 @@ function entersGitMetadata(root, resolved) {
51
50
  }
52
51
  /**
53
52
  * Absolute, symlink-resolved form of `candidate` if it lies inside `root`,
54
- * is not itself (and does not lie under) `.git/`, and is not one of nax's own
55
- * config files.
53
+ * is not itself (and does not lie under) `.git/`, and is not refused by the
54
+ * injected OwnedPathsPolicy's `configRefusal` (S3-2 port) -- for nax, one of
55
+ * nax's own config files.
56
56
  *
57
57
  * The single containment seam. Multi-root support (a future configurable
58
58
  * extension) changes this function and nothing else, which is why every tool
@@ -64,7 +64,9 @@ function entersGitMetadata(root, resolved) {
64
64
  * so containment alone never bars it, and an unconditional ("*") grant --
65
65
  * what every non-Exec tool gets under the default `unrestricted` profile --
66
66
  * skips glob matching entirely, so nothing downstream of this function would
67
- * catch it either.
67
+ * catch it either. The owned-config refusal arrives through the port: nax
68
+ * injects `naxOwnedPathsPolicy`, an embedder that injects nothing gets
69
+ * `EMPTY_OWNED_PATHS_POLICY` and only containment plus `.git/` remain.
68
70
  *
69
71
  * Pre-single-frame, this seam carried one exception: an `execTouchedPaths` set
70
72
  * (Task 10) admitted a workspace install's repo-ROOT manifest/lockfile even
@@ -73,11 +75,11 @@ function entersGitMetadata(root, resolved) {
73
75
  * manifest is in-root by construction and that carve-out was retired
74
76
  * (PR2/Task 13). There is no exception to this seam now.
75
77
  */
76
- export function resolveWithin(root, candidate) {
78
+ export function resolveWithin(root, candidate, owned) {
77
79
  const absolute = isAbsolute(candidate) ? candidate : resolve(root, candidate);
78
80
  if (isInside(root, absolute)) {
79
81
  const resolved = realOrRaw(absolute);
80
- if (entersGitMetadata(root, resolved) || isNaxConfigFile(root, resolved))
82
+ if (entersGitMetadata(root, resolved) || owned.configRefusal(root, resolved) !== undefined)
81
83
  return null;
82
84
  return resolved;
83
85
  }
@@ -118,14 +120,13 @@ export function resolveWithin(root, candidate) {
118
120
  * message is where the agent first sees the absolute containment root -- and
119
121
  * naming a path the model itself handed in is the right disclosure.
120
122
  */
121
- function outOfRootReason(root, candidate) {
123
+ function outOfRootReason(root, candidate, owned) {
122
124
  const absolute = isAbsolute(candidate) ? candidate : resolve(root, candidate);
123
- if (isInside(root, absolute) && isNaxConfigFile(root, realOrRaw(absolute))) {
124
- return ("is one of nax's own config files, which every tool is refused regardless of grant -- " +
125
- "`quality.commands` and `acceptance.command` are run through a shell WITHOUT passing the " +
126
- "permission gate because a human wrote them, so editing this file is a route to running " +
127
- "an ungated command on the next run");
128
- }
125
+ // The owned-config refusal (for nax: nax's own config files) comes from the
126
+ // injected port (S3-2), which supplies the full reason text.
127
+ const configReason = isInside(root, absolute) ? owned.configRefusal(root, realOrRaw(absolute)) : undefined;
128
+ if (configReason !== undefined)
129
+ return configReason;
129
130
  if (isInside(root, absolute) && entersGitMetadata(root, realOrRaw(absolute))) {
130
131
  return ("targets git metadata under .git/, which every tool is refused regardless of grant -- " +
131
132
  "writing there can corrupt the repository beyond git's own recovery, and reading its " +
@@ -168,9 +169,9 @@ function stripConfinePrefix(confinePrefix, value) {
168
169
  * fields, `restrictPaths` for array/ref fields — see the module comment).
169
170
  */
170
171
  function checkPathSegment(frame, candidate, enforceGlobs) {
171
- const resolved = resolveWithin(frame.effectiveRoot, stripConfinePrefix(frame.confinePrefix, candidate.path));
172
+ const resolved = resolveWithin(frame.effectiveRoot, stripConfinePrefix(frame.confinePrefix, candidate.path), frame.ctx.ownedPaths);
172
173
  if (resolved === null) {
173
- return frame.ctx.deny(`${candidate.denialSubject} ${outOfRootReason(frame.effectiveRoot, candidate.outOfRootTarget)}`, true);
174
+ return frame.ctx.deny(`${candidate.denialSubject} ${outOfRootReason(frame.effectiveRoot, candidate.outOfRootTarget, frame.ctx.ownedPaths)}`, true);
174
175
  }
175
176
  const rel = repoRelative(frame.ctx.resolvedRoot, resolved);
176
177
  const ruleDenial = frame.ctx.applyPathRules(frame.tool, rel, frame.state);
@@ -269,14 +270,14 @@ function runRefPathFields(frame) {
269
270
  * `confineTo` (tool-declared, see `ToolScope`) shifts the root passed to
270
271
  * `resolveWithin` from `<root>` to `<root>/<confineTo>`: containment stays
271
272
  * the one seam, and only its ROOT changes. `relativeTo` keeps rooting at
272
- * `resolvedRoot` so grant globs, deny rules and `naxOwnedWriteRefusal`
273
- * continue to see the canonical repo-root-relative spelling -- authors
273
+ * `resolvedRoot` so grant globs, deny rules and the injected policy's
274
+ * `writeRefusal` continue to see the canonical repo-root-relative spelling -- authors
274
275
  * write `.nax/scratchpad/**`, never `**`, regardless of `confineTo`.
275
276
  *
276
277
  * `confineTo` is bound to stay INSIDE `resolvedRoot`: an authoring typo of
277
278
  * `..` or `../shared` would otherwise widen the containment root past the
278
- * policy boundary and re-scope `resolveWithin`'s `.git/`-metadata and
279
- * `isNaxConfigFile` protections to a root that no longer aligns with the
279
+ * policy boundary and re-scope `resolveWithin`'s `.git/`-metadata and the
280
+ * port's `configRefusal` protections to a root that no longer aligns with the
280
281
  * segments those checks assume -- the repo's own `.nax/config.json` would
281
282
  stop being segment-matched against `.nax`. The boundary is the policy
282
283
  root's invariant, so an out-of-root confineTo refuses the call outright
@@ -14,6 +14,7 @@
14
14
  * exception to the boundary is needed.
15
15
  */
16
16
  import type { BashApprovalMode } from "#src/config/bash-approval";
17
+ import { type OwnedPathsPolicy } from "./owned-paths.ts";
17
18
  import { resolveWithin } from "./policy-paths-branch.ts";
18
19
  import { type ToolGrant, type ToolPolicy } from "./types.ts";
19
20
  /** The single containment seam now lives beside the path branch that drives it. */
@@ -32,16 +33,18 @@ export interface ToolPolicyOptions {
32
33
  */
33
34
  readonly askRules?: readonly ToolGrant[];
34
35
  /**
35
- * The ONE nax-owned path this session may write despite `naxOwnedWriteRefusal`
36
- * (nax#2115): the ABSOLUTE `fileOutput` path the dispatching op declared. It is
37
- * canonicalised into this policy's own root-relative frame below rather than by
38
- * the caller, so alternate spellings of the same file cannot diverge from the
39
- * string the guard compares. Only the feature-PRD refusal honours it; the nax
40
- * CONFIG refusal is deliberately not exempted.
36
+ * The ONE nax-owned path this session may write despite the port's
37
+ * `writeRefusal` (nax#2115): the ABSOLUTE `fileOutput` path the dispatching op
38
+ * declared. It is canonicalised into this policy's own root-relative frame below
39
+ * rather than by the caller, so alternate spellings of the same file cannot
40
+ * diverge from the string the guard compares. Only the feature-PRD refusal
41
+ * honours it; the owned CONFIG refusal is deliberately not exempted.
41
42
  */
42
43
  readonly ownedWriteExemption?: string;
43
- /** nax#2260: `execution.sandbox.filesystem.allowWrite`; a listed top-level `.nax/` entry becomes writable. */
44
+ /** nax#2260: `execution.sandbox.filesystem.allowWrite`; a listed top-level `.nax/` entry becomes writable through the port's `writeOptIns`. */
44
45
  readonly naxAllowWrite?: readonly string[];
46
+ /** S3-2: host-owned path rules (OwnedPathsPolicy port). Absent: none, the embedder default. */
47
+ readonly ownedPaths?: OwnedPathsPolicy;
45
48
  /**
46
49
  * How a bash command string is adjudicated (ADR-030). Absent means `gated` —
47
50
  * today's behaviour — so every caller that does not opt in is unchanged.
@@ -16,7 +16,7 @@
16
16
  import { relative, resolve, sep } from "node:path";
17
17
  import { realOrRaw } from "#src/internal/realpath";
18
18
  import { validateArgv } from "./exec-guard.js";
19
- import { naxOwnedWriteRefusal, naxWriteOptIns } from "./nax-owned-writes.js";
19
+ import { EMPTY_OWNED_PATHS_POLICY } from "./owned-paths.js";
20
20
  import { commandBranch } from "./policy-command-branch.js";
21
21
  import { compileArgvPattern, compileRuleMap, globToRegExp, matchedArgvSource, matchedGlobSource, matchedRulePatterns, matchesAny, matchesArgvGrant, } from "./policy-match.js";
22
22
  import { pathsBranch, resolveWithin } from "./policy-paths-branch.js";
@@ -35,13 +35,14 @@ function isFieldlessScope(scope) {
35
35
  }
36
36
  export function compileToolPolicy(grants, root, options) {
37
37
  const resolvedRoot = realOrRaw(root);
38
+ const owned = options?.ownedPaths ?? EMPTY_OWNED_PATHS_POLICY;
38
39
  // nax#2115: the SAME transform `relativeTo` applies to every checked path, so
39
40
  // the guard compares like with like. A path outside the root yields a
40
41
  // ".."-prefixed rel that can never equal a checked path's, exempting nothing.
41
42
  const ownedWriteExemption = options?.ownedWriteExemption === undefined
42
43
  ? undefined
43
44
  : relative(resolvedRoot, realOrRaw(options.ownedWriteExemption)).split(sep).join("/");
44
- const naxOptIns = naxWriteOptIns(root, options?.naxAllowWrite ?? []);
45
+ const naxOptIns = owned.writeOptIns(root, options?.naxAllowWrite ?? []);
45
46
  const denyBy = compileRuleMap(options?.denyRules);
46
47
  const askBy = compileRuleMap(options?.askRules);
47
48
  const bashApproval = options?.bashApproval ?? "gated";
@@ -87,9 +88,9 @@ export function compileToolPolicy(grants, root, options) {
87
88
  * denied and an ask on an ungranted call never becomes an approval prompt.
88
89
  */
89
90
  function applyPathRules(tool, rel, state) {
90
- const naxOwned = naxOwnedWriteRefusal(tool, rel, ownedWriteExemption, naxOptIns);
91
- if (naxOwned !== undefined)
92
- return deny(`${tool} may not modify ${naxOwned}`);
91
+ const ownedRefusal = owned.writeRefusal(tool, rel, { exemptRel: ownedWriteExemption, optIns: naxOptIns });
92
+ if (ownedRefusal !== undefined)
93
+ return deny(`${tool} may not modify ${ownedRefusal}`);
93
94
  const denyEntry = denyBy.get(tool);
94
95
  const askEntry = askBy.get(tool);
95
96
  if (denyEntry !== undefined && (denyEntry.unconditional || matchesAny(denyEntry.matchers, rel))) {
@@ -187,7 +188,7 @@ export function compileToolPolicy(grants, root, options) {
187
188
  * the nested helpers below stay authoritative, and the sibling reads them at
188
189
  * call time — `pathsBranch` itself lives in `./policy-paths-branch`.
189
190
  */
190
- const pathsBranchContext = { resolvedRoot, deny, askVerdict, applyPathRules };
191
+ const pathsBranchContext = { resolvedRoot, deny, askVerdict, applyPathRules, ownedPaths: owned };
191
192
  return {
192
193
  root: resolvedRoot,
193
194
  grantedTools() {
@@ -217,10 +218,11 @@ export function compileToolPolicy(grants, root, options) {
217
218
  bashApproval,
218
219
  rawBashRefusal: options?.rawBashRefusal,
219
220
  sandboxWrapped: options?.sandboxWrapped,
221
+ ownedPaths: owned,
220
222
  resolvedRoot,
221
223
  denyBy,
222
224
  askBy,
223
- resolvePath: (candidate, cwd) => resolveWithin(resolvedRoot, resolve(cwd, candidate)),
225
+ resolvePath: (candidate, cwd) => resolveWithin(resolvedRoot, resolve(cwd, candidate), owned),
224
226
  deny,
225
227
  askVerdict,
226
228
  }) ??
@@ -9,12 +9,12 @@ export interface ProtectedPathsPolicy {
9
9
  readonly gitExcludePathspecs: readonly string[];
10
10
  /** Gitignore patterns GitCommit refuses to stage. */
11
11
  readonly gitIgnorePatterns: readonly string[];
12
- /** Project-relative directory whose top-level entries the sandbox policy lists. */
13
- readonly projectStateDir: string;
14
- /** Directory whose `credentials*` files the sandbox denies. */
15
- readonly credentialDir: string;
16
- /** File the sandbox denies writes to: the trust store deciding whether repository code runs. */
17
- readonly trustStoreFile: string;
12
+ /** Project-relative directory whose top-level entries the sandbox policy lists. Absent: the sandbox skips it (S3 spec 6.2). */
13
+ readonly projectStateDir?: string;
14
+ /** Directory whose `credentials*` files the sandbox denies. Absent: the sandbox skips it (S3 spec 6.2). */
15
+ readonly credentialDir?: string;
16
+ /** File the sandbox denies writes to: the trust store deciding whether repository code runs. Absent: the sandbox skips it (S3 spec 6.2). */
17
+ readonly trustStoreFile?: string;
18
18
  }
19
19
  /** The Git tool's default excludes; none when the session carries no policy. */
20
20
  export declare function gitExcludePathspecsOf(ctx: {
@@ -22,6 +22,7 @@
22
22
  * both.
23
23
  */
24
24
  import { readPrefix } from "#src/internal/bounded-io";
25
+ import { credentialReadRefusal } from "./credential-read-deny.js";
25
26
  import { applyCapCut, limitStopFooter, shouldAppendLimitStopFooter } from "./read-continuation.js";
26
27
  import { MODEL_MAX_BYTES, MODEL_MAX_LINES, READ_CEILING, splitModelLines } from "./truncate.js";
27
28
  /** Range arguments models invent instead of offset/limit -- rejected by name, never silently dropped. */
@@ -41,6 +42,34 @@ function countLines(prefix) {
41
42
  const trimmed = prefix.endsWith("\n") ? prefix.slice(0, -1) : prefix;
42
43
  return trimmed.split("\n").length;
43
44
  }
45
+ /**
46
+ * The range half of Read's input: the unsupported-alias rejection (#1923) plus
47
+ * offset/limit parsing. Extracted from `run` so the credential read-deny adds
48
+ * no complexity there.
49
+ */
50
+ function parseRange(input) {
51
+ const usedAlias = UNSUPPORTED_RANGE_ALIASES.find((alias) => input[alias] !== undefined);
52
+ if (usedAlias !== undefined) {
53
+ return {
54
+ error: `"${usedAlias}" is not a supported Read argument -- use offset (1-based line number) and/or limit (line count) instead`,
55
+ };
56
+ }
57
+ let offset = 1;
58
+ if (input.offset !== undefined) {
59
+ const parsed = parsePositiveInt(input.offset, "offset");
60
+ if (typeof parsed === "string")
61
+ return { error: parsed };
62
+ offset = parsed;
63
+ }
64
+ let limit;
65
+ if (input.limit !== undefined) {
66
+ const parsed = parsePositiveInt(input.limit, "limit");
67
+ if (typeof parsed === "string")
68
+ return { error: parsed };
69
+ limit = parsed;
70
+ }
71
+ return { offset, limit };
72
+ }
44
73
  export const readTool = {
45
74
  name: "Read",
46
75
  description: "Read a UTF-8 text file from the repository. Paths are relative to the repository root. " +
@@ -64,15 +93,18 @@ export const readTool = {
64
93
  const [target] = ctx.resolvedPaths;
65
94
  if (target === undefined)
66
95
  return { content: "no path supplied", isError: true };
67
- const usedAlias = UNSUPPORTED_RANGE_ALIASES.find((alias) => input[alias] !== undefined);
68
- if (usedAlias !== undefined) {
69
- return {
70
- content: `"${usedAlias}" is not a supported Read argument -- use offset (1-based line number) and/or limit (line count) instead`,
71
- isError: true,
72
- };
96
+ // S3 spec 6.3: a workdir may resolve onto the host's credential directory
97
+ // or trust store; the read tools refuse those regardless of grant.
98
+ const credential = credentialReadRefusal(ctx.protectedPaths, target);
99
+ if (credential !== undefined) {
100
+ return { content: `path "${String(input.path)}" ${credential}`, isError: true };
73
101
  }
74
102
  const hasOffset = input.offset !== undefined;
75
103
  const hasLimit = input.limit !== undefined;
104
+ const range = parseRange(input);
105
+ if ("error" in range)
106
+ return { content: range.error, isError: true };
107
+ const { offset, limit } = range;
76
108
  try {
77
109
  if (!hasOffset && !hasLimit) {
78
110
  // Leading [N lines] header so an agent has a way to count the lines it
@@ -106,20 +138,6 @@ export const readTool = {
106
138
  });
107
139
  return { content: result.content };
108
140
  }
109
- let offset = 1;
110
- if (hasOffset) {
111
- const parsed = parsePositiveInt(input.offset, "offset");
112
- if (typeof parsed === "string")
113
- return { content: parsed, isError: true };
114
- offset = parsed;
115
- }
116
- let limit;
117
- if (hasLimit) {
118
- const parsed = parsePositiveInt(input.limit, "limit");
119
- if (typeof parsed === "string")
120
- return { content: parsed, isError: true };
121
- limit = parsed;
122
- }
123
141
  // Read up to maxFileBytes, not maxBytes: the requested range may start
124
142
  // past the display ceiling, and we still need to reach it to slice.
125
143
  const body = await readPrefix(target, ctx.maxFileBytes);
@@ -12,6 +12,7 @@
12
12
  import type { CommandInterceptor } from "#src/command-interceptor/index";
13
13
  import type { JSONSchema } from "#src/session/tool-descriptor";
14
14
  import type { SandboxRecord } from "../sandbox/index.ts";
15
+ import type { OwnedPathsPolicy } from "./owned-paths.ts";
15
16
  import type { ProtectedPathsPolicy } from "./protected-paths.ts";
16
17
  import type { CodingToolName, ToolScope } from "./types.ts";
17
18
  export interface ToolResult {
@@ -96,6 +97,8 @@ export interface ToolRunContext {
96
97
  readonly interceptor?: CommandInterceptor;
97
98
  /** Host-owned paths (S1 spec port 6). Absent: the Git tools exclude and skip nothing extra. */
98
99
  readonly protectedPaths?: ProtectedPathsPolicy;
100
+ /** Host-owned path rules (S3-2 port). Absent: no owned-path refusals. */
101
+ readonly ownedPaths?: OwnedPathsPolicy;
99
102
  }
100
103
  export interface CodingTool {
101
104
  readonly name: string;
@@ -112,6 +112,7 @@ export async function runExecBranch(input, ctx, opts) {
112
112
  packageWorkdir: opts.exec.packageWorkdir,
113
113
  packageRelPath,
114
114
  allowScripts: opts.exec.allowScripts,
115
+ ownedPaths: ctx.ownedPaths,
115
116
  ...(opts.exec.packageName !== undefined ? { packageName: opts.exec.packageName } : {}),
116
117
  });
117
118
  if ("error" in normalized)