@nathapp/nax-agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +47 -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 +10 -1
  8. package/dist/index.js +4 -1
  9. package/dist/internal/redact.d.ts +23 -0
  10. package/dist/internal/redact.js +60 -0
  11. package/dist/internal.d.ts +7 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/native/adapter-deps.d.ts +3 -3
  14. package/dist/native/adapter-deps.js +3 -3
  15. package/dist/native/client.d.ts +7 -1
  16. package/dist/native/client.js +1 -1
  17. package/dist/native/credentials/change-guard.d.ts +8 -1
  18. package/dist/native/credentials/change-guard.js +1 -1
  19. package/dist/native/credentials/fingerprint.d.ts +4 -1
  20. package/dist/native/credentials/fingerprint.js +6 -3
  21. package/dist/native/credentials/session-source.d.ts +12 -0
  22. package/dist/native/credentials/session-source.js +33 -0
  23. package/dist/native/index.d.ts +3 -1
  24. package/dist/native/index.js +2 -1
  25. package/dist/native/session/loop-events/types.d.ts +1 -1
  26. package/dist/native/session/loop-handlers.d.ts +3 -0
  27. package/dist/native/session/loop-handlers.js +1 -1
  28. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  29. package/dist/native/session/memory-transcript-store.js +36 -0
  30. package/dist/native/session/session.d.ts +138 -96
  31. package/dist/native/session/session.js +107 -127
  32. package/dist/native/session/transcript-identity.d.ts +39 -0
  33. package/dist/native/session/transcript-identity.js +81 -0
  34. package/dist/native/session/transcript-store.d.ts +22 -21
  35. package/dist/native/session/transcript-store.js +59 -68
  36. package/dist/native/session/transcript-types.d.ts +38 -0
  37. package/dist/native/session/transcript-types.js +7 -0
  38. package/dist/native/session/truncation-handler.d.ts +4 -3
  39. package/dist/native/session/truncation-handler.js +7 -10
  40. package/dist/native/session/turn-complete-step.d.ts +4 -1
  41. package/dist/native/session/turn-complete-step.js +11 -4
  42. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  43. package/dist/native/session/turn-event-emitter.js +134 -0
  44. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  45. package/dist/native/session/turn-loop-round-trip.js +8 -4
  46. package/dist/native/session/turn-loop.js +27 -13
  47. package/dist/native/session/turn-result.d.ts +1 -1
  48. package/dist/native/session/turn-result.js +1 -1
  49. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  50. package/dist/native/session/turn-tool-batch.js +14 -6
  51. package/dist/native/session/turn-types.d.ts +17 -1
  52. package/dist/native/session-adapter.d.ts +22 -4
  53. package/dist/native/session-adapter.js +79 -35
  54. package/dist/native/stream-complete.d.ts +17 -0
  55. package/dist/native/stream-complete.js +40 -0
  56. package/dist/permissions/approvals-link.js +5 -4
  57. package/dist/sandbox/policy-builder.d.ts +6 -1
  58. package/dist/sandbox/policy-builder.js +18 -14
  59. package/dist/session/agent-session-deps.d.ts +12 -0
  60. package/dist/session/agent-session-deps.js +28 -0
  61. package/dist/session/agent-session-errors.d.ts +11 -0
  62. package/dist/session/agent-session-errors.js +12 -0
  63. package/dist/session/agent-session-options.d.ts +16 -0
  64. package/dist/session/agent-session-options.js +137 -0
  65. package/dist/session/agent-session-resume.d.ts +12 -0
  66. package/dist/session/agent-session-resume.js +47 -0
  67. package/dist/session/agent-session-turn.d.ts +45 -0
  68. package/dist/session/agent-session-turn.js +179 -0
  69. package/dist/session/agent-session-types.d.ts +182 -0
  70. package/dist/session/agent-session-types.js +1 -0
  71. package/dist/session/agent-session.d.ts +8 -0
  72. package/dist/session/agent-session.js +223 -0
  73. package/dist/session/pending-asks.d.ts +24 -0
  74. package/dist/session/pending-asks.js +77 -0
  75. package/dist/session/session-ask-link.d.ts +26 -0
  76. package/dist/session/session-ask-link.js +52 -0
  77. package/dist/session/session-event-channel.d.ts +17 -0
  78. package/dist/session/session-event-channel.js +132 -0
  79. package/dist/session/session-interaction.d.ts +20 -0
  80. package/dist/session/session-interaction.js +164 -0
  81. package/dist/session/session-tool-support.d.ts +48 -0
  82. package/dist/session/session-tool-support.js +101 -0
  83. package/dist/session/session-types.d.ts +31 -2
  84. package/dist/session/turn-event.d.ts +61 -0
  85. package/dist/session/turn-event.js +1 -0
  86. package/dist/tools/bash-cwd.d.ts +3 -3
  87. package/dist/tools/credential-read-deny.d.ts +22 -0
  88. package/dist/tools/credential-read-deny.js +56 -0
  89. package/dist/tools/glob.js +9 -2
  90. package/dist/tools/grep.js +55 -26
  91. package/dist/tools/owned-paths.d.ts +43 -0
  92. package/dist/tools/owned-paths.js +17 -0
  93. package/dist/tools/package-managers.d.ts +3 -0
  94. package/dist/tools/package-managers.js +7 -3
  95. package/dist/tools/policy-bash-raw.d.ts +3 -0
  96. package/dist/tools/policy-bash-raw.js +49 -62
  97. package/dist/tools/policy-command-branch.d.ts +3 -0
  98. package/dist/tools/policy-command-branch.js +2 -1
  99. package/dist/tools/policy-paths-branch.d.ts +14 -8
  100. package/dist/tools/policy-paths-branch.js +20 -19
  101. package/dist/tools/policy.d.ts +10 -7
  102. package/dist/tools/policy.js +9 -7
  103. package/dist/tools/protected-paths.d.ts +6 -6
  104. package/dist/tools/read.js +38 -20
  105. package/dist/tools/registry.d.ts +3 -0
  106. package/dist/tools/run-command-exec.js +1 -0
  107. package/dist/tools/runtime.d.ts +3 -0
  108. package/dist/tools/runtime.js +1 -0
  109. package/dist/tools/scratchpad.js +2 -1
  110. package/dist/tools/types.d.ts +2 -2
  111. package/package.json +2 -2
  112. package/dist/tools/nax-owned-writes.d.ts +0 -135
  113. package/dist/tools/nax-owned-writes.js +0 -259
@@ -13,6 +13,7 @@
13
13
  import { drainBounded } from "#src/internal/bounded-io";
14
14
  import { runtimeSpawn } from "#src/runtime/index";
15
15
  import { which } from "#src/runtime/which";
16
+ import { containsCredentialPath, credentialReadRefusal } from "./credential-read-deny.js";
16
17
  import { cutToByteCap, READ_CEILING } from "./truncate.js";
17
18
  const GREP_TIMEOUT_MS = 15_000;
18
19
  /** @internal */
@@ -30,6 +31,52 @@ export const _grepDeps = {
30
31
  which: (name) => which(name),
31
32
  spawn: runtimeSpawn,
32
33
  };
34
+ /**
35
+ * The two independent caveats a search may carry, either of which means the
36
+ * search the caller asked for is not quite the search that ran:
37
+ *
38
+ * 1. The literal disclosure fires whenever the search was literal AND the
39
+ * pattern contained a metacharacter a regex would have treated specially --
40
+ * regardless of whether it matched. #1876 only covered the zero-match case;
41
+ * a literal match on a pattern like "foo.bar" is the WORSE case (#1922):
42
+ * the caller has positive evidence and no cue that "." was never a
43
+ * wildcard, so a real regex match was never attempted.
44
+ *
45
+ * 2. The dialect note fires when regex mode ran through the grep fallback.
46
+ * ripgrep's Rust regex and POSIX ERE are NOT the same language: `\d`, `\w`
47
+ * and `\b` are unsupported in ERE, and whether they degrade to a literal or
48
+ * to something else differs BY PLATFORM (GNU grep treats `\d` as `d`; the
49
+ * BSD grep on macOS matches a digit). Silently returning a different answer
50
+ * depending on which binary the machine happens to have is exactly the
51
+ * failure this file's header forbids of the fallback, so the divergence is
52
+ * disclosed rather than hidden.
53
+ */
54
+ function searchCaveat(mode, binary, pattern) {
55
+ const notes = [];
56
+ if (mode === "literal" && containsRegexMetacharacter(pattern)) {
57
+ notes.push("The search was performed literally and regex metacharacters were not interpreted.");
58
+ }
59
+ if (mode === "regex" && binary === "grep") {
60
+ notes.push("ripgrep is not installed here, so the pattern was matched with POSIX ERE via grep: escapes such as \\d, \\w and \\b are NOT supported — use [0-9], [A-Za-z0-9_] and similar instead.");
61
+ }
62
+ return notes.join(" ");
63
+ }
64
+ /**
65
+ * The credential read-deny for a search rooted at `searchRoot` (S3 spec 6.3),
66
+ * or undefined. Two shapes: the search root itself is a credential path, and
67
+ * the subtler one where a subtree of `searchRoot` contains it — a root-level
68
+ * search on a workdir laid over the host's home would read the credential
69
+ * directory's contents via matches.
70
+ */
71
+ function credentialSearchRefusal(ctx, searchRoot, shown) {
72
+ const inside = credentialReadRefusal(ctx.protectedPaths, searchRoot);
73
+ if (inside !== undefined)
74
+ return `path "${shown}" ${inside}`;
75
+ if (!containsCredentialPath(ctx.protectedPaths, searchRoot))
76
+ return undefined;
77
+ return ("this search would reach the host's credential directory or trust store, which the read tools are refused; " +
78
+ "narrow the search to a subdirectory that does not contain it");
79
+ }
33
80
  export function buildGrepArgv(binary, pattern, path, patternType = "literal") {
34
81
  const target = path ?? ".";
35
82
  // `--` terminates flag parsing on both branches, in both modes: a pattern
@@ -87,6 +134,11 @@ export const grepTool = {
87
134
  return { content: "neither ripgrep nor grep is available on this machine", isError: true };
88
135
  }
89
136
  const [target] = ctx.resolvedPaths;
137
+ // S3 spec 6.3: the search must not reach the host's credential directory
138
+ // or trust store, whatever grant resolved the target.
139
+ const refusal = credentialSearchRefusal(ctx, target ?? ctx.root, typeof input.path === "string" ? input.path : ".");
140
+ if (refusal !== undefined)
141
+ return { content: refusal, isError: true };
90
142
  // The I/O ceiling, not the model-facing cap: `maxBytes` shapes what the
91
143
  // model is told and belongs to the session's truncation policy, while this
92
144
  // bound only keeps a search whose result is enormous from being buffered
@@ -118,32 +170,9 @@ export const grepTool = {
118
170
  const exitCode = await proc.exited;
119
171
  clearTimeout(timer);
120
172
  const stdout = await stdoutText;
121
- // Two independent caveats, either of which means the search the caller
122
- // asked for is not quite the search that ran.
123
- //
124
- // 1. The literal disclosure fires whenever the search was literal AND the
125
- // pattern contained a metacharacter a regex would have treated
126
- // specially -- regardless of whether it matched. #1876 only covered the
127
- // zero-match case; a literal match on a pattern like "foo.bar" is the
128
- // WORSE case (#1922): the caller has positive evidence and no cue that
129
- // "." was never a wildcard, so a real regex match was never attempted.
130
- //
131
- // 2. The dialect note fires when regex mode ran through the grep fallback.
132
- // ripgrep's Rust regex and POSIX ERE are NOT the same language: `\d`,
133
- // `\w` and `\b` are unsupported in ERE, and whether they degrade to a
134
- // literal or to something else differs BY PLATFORM (GNU grep treats
135
- // `\d` as `d`; the BSD grep on macOS matches a digit). Silently
136
- // returning a different answer depending on which binary the machine
137
- // happens to have is exactly the failure this file's header forbids of
138
- // the fallback, so the divergence is disclosed rather than hidden.
139
- const notes = [];
140
- if (mode === "literal" && containsRegexMetacharacter(pattern)) {
141
- notes.push("The search was performed literally and regex metacharacters were not interpreted.");
142
- }
143
- if (mode === "regex" && binary === "grep") {
144
- notes.push("ripgrep is not installed here, so the pattern was matched with POSIX ERE via grep: escapes such as \\d, \\w and \\b are NOT supported — use [0-9], [A-Za-z0-9_] and similar instead.");
145
- }
146
- const caveat = notes.join(" ");
173
+ // Two independent caveats may apply -- the literal disclosure and the grep
174
+ // fallback's ERE dialect note. Their rules live in `searchCaveat` (above).
175
+ const caveat = searchCaveat(mode, binary, pattern);
147
176
  // Both binaries exit 1 for "no matches" — a normal outcome, not a failure.
148
177
  if (exitCode === 1 && stdout.trim() === "") {
149
178
  const base = `no matches for "${pattern}"`;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The owned-paths port (S3 spec 6.5, arc decision D16): which paths the HOST
3
+ * owns the writes to, and how its refusals read. nax-agent holds no such
4
+ * knowledge itself; nax injects its policy through `buildCodingToolSupport`
5
+ * and `resolveSessionSandbox`, and an embedder that injects nothing gets
6
+ * `EMPTY_OWNED_PATHS_POLICY`: containment and the `.git/` refusal still apply,
7
+ * owned-path refusals do not.
8
+ */
9
+ /** One working-directory frame of a raw Bash token, in frame order. */
10
+ export interface OwnedBashCandidate {
11
+ /** The token resolved lexically against one working directory, symlinks resolved (`realOrRaw`). */
12
+ readonly lexical: string;
13
+ /** The typed-seam resolver's root-relative, `/`-joined path for that frame; null when it refused or the path is outside the root. */
14
+ readonly rel: string | null;
15
+ }
16
+ export interface OwnedPathsPolicy {
17
+ /** Refusal for a mutating path tool on a root-relative, `/`-joined path; undefined = not owned. */
18
+ writeRefusal(tool: string, rel: string, ctx: {
19
+ readonly exemptRel?: string;
20
+ readonly optIns: ReadonlySet<string>;
21
+ }): string | undefined;
22
+ /**
23
+ * Refusal for ANY tool, reads included, on a symlink-resolved absolute path;
24
+ * undefined = not refused. Non-undefined makes `resolveWithin` refuse the path,
25
+ * and the text completes `outOfRootReason`'s `path "x" <text>` sentence.
26
+ */
27
+ configRefusal(root: string, resolved: string): string | undefined;
28
+ /** Raw Bash screen: full refusal for `token` given its per-frame candidates; undefined = allowed. */
29
+ bashRefusal(tool: string, token: string, candidates: readonly OwnedBashCandidate[], ctx: {
30
+ readonly root: string;
31
+ readonly verb: "names" | "redirects into";
32
+ readonly sandboxWrapped: boolean;
33
+ }): string | undefined;
34
+ /** Top-level project-state entries the sandbox denies writes to, even when absent. */
35
+ readonly deniedEntries: readonly string[];
36
+ /** Root-level file names the sandbox denies writes to. */
37
+ readonly rootWriteDenies: readonly string[];
38
+ /** The one project-state entry agents may always write; never sandbox-denied. */
39
+ readonly scratchpadEntry?: string;
40
+ /** Project-state entries opened for writes by `allowWrite`. */
41
+ writeOptIns(root: string, allowWrite: readonly string[]): ReadonlySet<string>;
42
+ }
43
+ export declare const EMPTY_OWNED_PATHS_POLICY: OwnedPathsPolicy;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The owned-paths port (S3 spec 6.5, arc decision D16): which paths the HOST
3
+ * owns the writes to, and how its refusals read. nax-agent holds no such
4
+ * knowledge itself; nax injects its policy through `buildCodingToolSupport`
5
+ * and `resolveSessionSandbox`, and an embedder that injects nothing gets
6
+ * `EMPTY_OWNED_PATHS_POLICY`: containment and the `.git/` refusal still apply,
7
+ * owned-path refusals do not.
8
+ */
9
+ const NO_OPT_INS = new Set();
10
+ export const EMPTY_OWNED_PATHS_POLICY = {
11
+ writeRefusal: () => undefined,
12
+ configRefusal: () => undefined,
13
+ bashRefusal: () => undefined,
14
+ deniedEntries: [],
15
+ rootWriteDenies: [],
16
+ writeOptIns: () => NO_OPT_INS,
17
+ };
@@ -66,12 +66,15 @@
66
66
  * a widening `Exec(...)` grant by hand — defense-in-depth, not a live hole
67
67
  * under the shipped default.
68
68
  */
69
+ import { type OwnedPathsPolicy } from "./owned-paths.ts";
69
70
  import type { NormalizeResult, WorkspaceContext } from "./package-managers-types.ts";
70
71
  export type { ExecTarget, NormalizeResult } from "./package-managers-types.ts";
71
72
  import type { ExecTarget } from "./package-managers-types.ts";
72
73
  export interface NormalizeInput extends WorkspaceContext {
73
74
  readonly argv: readonly string[];
74
75
  readonly target: ExecTarget;
76
+ /** Host-owned path rules (S3-2 port) handed to resolveWithin; absent: none (the empty policy). */
77
+ readonly ownedPaths?: OwnedPathsPolicy;
75
78
  }
76
79
  /**
77
80
  * Lowercases and strips a trailing executable suffix so `NPM`, `npm.cmd`
@@ -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.