@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
@@ -0,0 +1,48 @@
1
+ import { type CodingToolSupport } from "#src/coding-tools/coding-tool-support";
2
+ import type { CommandInterceptor } from "#src/command-interceptor/index";
3
+ import type { BashApprovalMode } from "#src/config/bash-approval";
4
+ import type { AskResolver } from "#src/permissions/index";
5
+ import type { CommandLauncher } from "#src/sandbox/index";
6
+ import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
7
+ import type { CodingToolName, ToolGrant } from "#src/tools/types";
8
+ import type { AgentSessionProfile } from "./agent-session-types.ts";
9
+ /** The pipeline stage the facade's tool calls and asks carry. */
10
+ export declare const SESSION_STAGE = "session";
11
+ export declare function declaredToolsFor(profile: AgentSessionProfile, protectedPaths: ProtectedPathsPolicy): readonly CodingToolName[];
12
+ export declare function grantsFor(declared: readonly CodingToolName[]): readonly ToolGrant[];
13
+ /**
14
+ * Ask rules evaluate after grants. Under "gated" every Bash command is put to
15
+ * the person. Under the "ask" profile every mutating path tool is too, and the
16
+ * profile forces "gated", so Bash always asks there.
17
+ */
18
+ export declare function askRulesFor(declared: readonly CodingToolName[], bashApproval: BashApprovalMode, profile: AgentSessionProfile): readonly ToolGrant[];
19
+ /**
20
+ * The embedder default (spec 6.2). A session with its own credentials source
21
+ * (memory or exec) has no credentials directory on disk; one that falls back
22
+ * to the configureCredentials slot protects that slot's directory.
23
+ */
24
+ export declare function defaultProtectedPaths(ownCredentials: boolean): ProtectedPathsPolicy;
25
+ export interface SessionLauncherArgs {
26
+ readonly profile: AgentSessionProfile;
27
+ readonly root: string;
28
+ readonly protectedPaths: ProtectedPathsPolicy;
29
+ readonly bashApproval: BashApprovalMode;
30
+ readonly allowUnsandboxed: boolean;
31
+ }
32
+ /** The sandbox floor (spec 6.3): "ask" and "full" need a usable sandbox unless gated + allowUnsandboxed. */
33
+ export declare function resolveSessionLauncher(args: SessionLauncherArgs): Promise<CommandLauncher | undefined>;
34
+ export interface SessionToolSupportArgs {
35
+ readonly profile: AgentSessionProfile;
36
+ readonly root: string;
37
+ readonly sessionName: string;
38
+ readonly protectedPaths: ProtectedPathsPolicy;
39
+ readonly bashApproval: BashApprovalMode;
40
+ readonly launcher: CommandLauncher | undefined;
41
+ readonly askResolver: AskResolver;
42
+ readonly interceptor: CommandInterceptor | undefined;
43
+ }
44
+ export interface SessionToolSupport {
45
+ readonly support: CodingToolSupport;
46
+ readonly grants: readonly ToolGrant[];
47
+ }
48
+ export declare function buildSessionToolSupport(args: SessionToolSupportArgs): SessionToolSupport;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Profile -> coding tools (spec 4.5, 6.2, 6.3). A profile is a capability
3
+ * statement; this module turns it into the declared tools and grants that
4
+ * buildCodingToolSupport takes, picks the embedder default protected-paths
5
+ * policy, and enforces the sandbox floor for "full".
6
+ */
7
+ import { resolveSessionSandbox } from "#src/coding-tools/coding-tool-sandbox";
8
+ import { buildCodingToolSupport } from "#src/coding-tools/coding-tool-support";
9
+ import { UNIVERSAL_CODING_TOOLS } from "#src/coding-tools/universal-coding-tools";
10
+ import { DEFAULT_SANDBOX_CONFIG } from "#src/config/schemas-sandbox";
11
+ import { credentialsConfig } from "#src/infra/credentials-config";
12
+ import { EMPTY_OWNED_PATHS_POLICY } from "#src/tools/owned-paths";
13
+ import { AgentSessionError } from "./agent-session-errors.js";
14
+ /** The pipeline stage the facade's tool calls and asks carry. */
15
+ export const SESSION_STAGE = "session";
16
+ const READ_TOOLS = ["Read", "Glob", "Grep", "Git"];
17
+ const WRITE_TOOLS = ["Write", "Edit", "Delete", "Bash"];
18
+ const ASK_PROFILE_TOOLS = ["Write", "Edit", "Delete", "GitCommit"];
19
+ export function declaredToolsFor(profile, protectedPaths) {
20
+ if (profile === "none")
21
+ return [...UNIVERSAL_CODING_TOOLS];
22
+ if (profile === "read")
23
+ return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS];
24
+ // GitCommit refuses every path without ignore patterns, so it is offered only with them.
25
+ const commit = protectedPaths.gitIgnorePatterns.length > 0 ? ["GitCommit"] : [];
26
+ return [...UNIVERSAL_CODING_TOOLS, ...READ_TOOLS, ...WRITE_TOOLS, ...commit];
27
+ }
28
+ export function grantsFor(declared) {
29
+ return declared.map((tool) => ({ tool, patterns: ["*"] }));
30
+ }
31
+ /**
32
+ * Ask rules evaluate after grants. Under "gated" every Bash command is put to
33
+ * the person. Under the "ask" profile every mutating path tool is too, and the
34
+ * profile forces "gated", so Bash always asks there.
35
+ */
36
+ export function askRulesFor(declared, bashApproval, profile) {
37
+ const bash = bashApproval === "gated" && declared.includes("Bash") ? ["Bash"] : [];
38
+ const mutating = profile === "ask" ? ASK_PROFILE_TOOLS.filter((tool) => declared.includes(tool)) : [];
39
+ return [...mutating, ...bash].map((tool) => ({ tool, patterns: ["*"] }));
40
+ }
41
+ /** The configured credentials directory; undefined when configureCredentials was never called. */
42
+ function configuredCredentialDir() {
43
+ try {
44
+ return credentialsConfig().configDir();
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ /**
51
+ * The embedder default (spec 6.2). A session with its own credentials source
52
+ * (memory or exec) has no credentials directory on disk; one that falls back
53
+ * to the configureCredentials slot protects that slot's directory.
54
+ */
55
+ export function defaultProtectedPaths(ownCredentials) {
56
+ const base = { gitExcludePathspecs: [], gitIgnorePatterns: [] };
57
+ const credentialDir = ownCredentials ? undefined : configuredCredentialDir();
58
+ return credentialDir === undefined ? base : { ...base, credentialDir };
59
+ }
60
+ /** The sandbox floor (spec 6.3): "ask" and "full" need a usable sandbox unless gated + allowUnsandboxed. */
61
+ export async function resolveSessionLauncher(args) {
62
+ if (args.profile !== "full" && args.profile !== "ask")
63
+ return undefined;
64
+ const launcher = await resolveSessionSandbox({
65
+ config: DEFAULT_SANDBOX_CONFIG,
66
+ root: args.root,
67
+ needsLauncher: true,
68
+ protectedPaths: args.protectedPaths,
69
+ ownedPaths: EMPTY_OWNED_PATHS_POLICY,
70
+ });
71
+ if (launcher.state.kind === "available")
72
+ return launcher;
73
+ if (args.bashApproval === "gated" && args.allowUnsandboxed)
74
+ return undefined;
75
+ const reason = launcher.state.kind === "unavailable" ? launcher.state.reason : "the sandbox is disabled";
76
+ throw new AgentSessionError(`Profile "${args.profile}" needs a usable sandbox (${reason}). Pass bashApproval "gated" with allowUnsandboxed: true to run without one.`, "AGENT_SESSION_SANDBOX_UNAVAILABLE", { reason });
77
+ }
78
+ export function buildSessionToolSupport(args) {
79
+ const declared = declaredToolsFor(args.profile, args.protectedPaths);
80
+ const grants = grantsFor(declared);
81
+ const askRules = askRulesFor(declared, args.bashApproval, args.profile);
82
+ const support = buildCodingToolSupport({
83
+ root: args.root,
84
+ commandCwd: args.root,
85
+ pipelineStage: SESSION_STAGE,
86
+ sessionName: args.sessionName,
87
+ declared,
88
+ grants,
89
+ bashApproval: args.bashApproval,
90
+ protectedPaths: args.protectedPaths,
91
+ ownedPaths: EMPTY_OWNED_PATHS_POLICY,
92
+ askResolver: args.askResolver,
93
+ ...(askRules.length > 0 ? { askRules } : {}),
94
+ ...(args.interceptor !== undefined ? { interceptor: args.interceptor } : {}),
95
+ ...(args.launcher !== undefined ? { launcher: args.launcher } : {}),
96
+ });
97
+ // Unreachable while the scratchpad trio is always declared and granted; kept for the type.
98
+ if (support === undefined) {
99
+ throw new AgentSessionError("No coding tools resolved for the session", "AGENT_SESSION_INVALID_OPTIONS", {
100
+ profile: args.profile,
101
+ });
102
+ }
103
+ return { support, grants };
104
+ }
@@ -26,8 +26,8 @@ import type { ToolDescriptor } from "./tool-descriptor.ts";
26
26
  export interface AuthStamp {
27
27
  /** Keyed credential digest (see `fingerprint.ts`). Not comparable across machines. */
28
28
  fingerprint: string;
29
- /** Where the credential was read from. */
30
- source: "file" | "exec";
29
+ /** Where the credential was read from: the `file` store, an `exec` helper, or an in-process `memory` store. */
30
+ source: "file" | "exec" | "memory";
31
31
  /** Account label the source reported, when it reports one. */
32
32
  account?: string;
33
33
  }
@@ -143,6 +143,28 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
143
143
  * boundary transcript resumption must respect (nax#1877). ACP ignores it.
144
144
  */
145
145
  transcriptOwner?: string;
146
+ /**
147
+ * Native: where the session's history lives (S3 spec 5.5). Mutually exclusive
148
+ * with `transcriptDir`, which is shorthand for the file store; exactly one of
149
+ * the two must be set. nax sets `transcriptDir`. ACP ignores it.
150
+ */
151
+ transcriptStore?: import("#src/native/session/transcript-types").TranscriptStore;
152
+ /**
153
+ * Native: leave the live transcript in place on close (S3 spec 5.5). By
154
+ * default a clean close deletes it and a failed close moves it aside; the
155
+ * facade sets this so the session stays resumable. nax leaves it unset.
156
+ * Limitation: the file store's prune on a failed close (cap 50, oldest first)
157
+ * counts every transcript in its directory, live ones included, so a
158
+ * retained session should not share a file-store directory with sessions
159
+ * that close failed.
160
+ */
161
+ retainOnClose?: boolean;
162
+ /**
163
+ * Native: the session's system prompt, sent as the request's top-level
164
+ * `system` on every round trip (not on the compaction summary). The S3
165
+ * facade sets it from `instructions`; nax leaves it unset. ACP ignores it.
166
+ */
167
+ systemPrompt?: string;
146
168
  /**
147
169
  * Native: resolved compaction settings. A resolved primitive, never NaxConfig —
148
170
  * src/agents/native/ must not read config (check:adapter-no-config-import).
@@ -208,6 +230,13 @@ export interface SendTurnOpts {
208
230
  * precisely so tool calls made during the turn can carry it.
209
231
  */
210
232
  turnId?: string;
233
+ /**
234
+ * S3-3: per-turn event sink (deltas, stream_reset, tool calls and results,
235
+ * per-round usage, compaction). Native honours it; ACP ignores it until S4.
236
+ * Called synchronously; a throw or rejection is contained by the backend.
237
+ * nax sets none.
238
+ */
239
+ onTurnEvent?: import("./turn-event.ts").TurnEventSink;
211
240
  }
212
241
  /**
213
242
  * A single mid-turn interactive Q&A exchange between the agent and a human
@@ -231,6 +260,8 @@ export interface TurnResult {
231
260
  estimatedCostUsd: number;
232
261
  /** Exact cost reported by wire protocol (when available). */
233
262
  exactCostUsd?: number;
263
+ /** Absent means computed. */
264
+ costSource?: import("./agent-session-types.ts").CostSource;
234
265
  /** Number of session.prompt() calls made. */
235
266
  internalRoundTrips: number;
236
267
  /**
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Per-turn events a backend pushes to `SendTurnOpts.onTurnEvent` (S3 spec
3
+ * 5.3, 5.4). Backend-neutral: the S3 facade adds `sessionId`, `turnId`, `at`
4
+ * and `metadata` and turns these into its `SessionEvent`s. nax sets no sink.
5
+ *
6
+ * - Deltas are provisional. `after_response` handlers may patch the recorded
7
+ * text, so the transcript and `TurnResult.output` are authoritative.
8
+ * - `stream_reset` voids the deltas of `round` so far: the round's request is
9
+ * being re-issued (a retry), and `attempt` is the new attempt's 1-based number.
10
+ * - `usage` is one per round-trip model call and marks the round's end.
11
+ * - `stream_reset` voids everything since the failed attempt began, including
12
+ * any retry backoff; the deltas after it are the new attempt's.
13
+ * - `usage` excludes the compaction summary call, so a turn's totals come from
14
+ * `TurnResult` (`tokenUsage`, `estimatedCostUsd`), not from summing events.
15
+ * - `tool_call.input` and `tool_result.preview` are redacted BEST-EFFORT (the
16
+ * logger's redactor: secret-named keys, known token shapes, KEY=value text)
17
+ * and byte-capped. A secret file's text can still appear; treat previews and
18
+ * inputs as sensitive. Text and thinking deltas are not redacted.
19
+ * - Every `tool_call` is followed by exactly one `tool_result`, also when the
20
+ * turn throws. A refusal is a non-error result carrying the refusal text.
21
+ * Calls the loop answers without running (spin stop, cancel) and `ask_human`
22
+ * emit no tool events; they appear only in the transcript.
23
+ */
24
+ export type TurnEvent = {
25
+ readonly type: "text_delta";
26
+ readonly round: number;
27
+ readonly text: string;
28
+ } | {
29
+ readonly type: "thinking_delta";
30
+ readonly round: number;
31
+ readonly text: string;
32
+ } | {
33
+ readonly type: "stream_reset";
34
+ readonly round: number;
35
+ readonly attempt: number;
36
+ } | {
37
+ readonly type: "tool_call";
38
+ readonly callId: string;
39
+ readonly name: string;
40
+ readonly input: unknown;
41
+ } | {
42
+ readonly type: "tool_result";
43
+ readonly callId: string;
44
+ readonly isError: boolean;
45
+ readonly preview: string;
46
+ } | {
47
+ readonly type: "usage";
48
+ readonly round: number;
49
+ readonly inputTokens: number;
50
+ readonly outputTokens: number;
51
+ /** Absent when the call reported no cache data -- never coerced to 0. */
52
+ readonly cacheRead?: number;
53
+ /** Absent when the call reported no cache data -- never coerced to 0. */
54
+ readonly cacheWrite?: number;
55
+ readonly costUsd: number;
56
+ /**
57
+ * Absent means computed from the catalog (the native backend). `unpriced`
58
+ * rows carry `costUsd: 0` and must not be summed as a real cost.
59
+ */
60
+ readonly costSource?: import("./agent-session-types.ts").CostSource;
61
+ } | {
62
+ readonly type: "compaction";
63
+ readonly reason: "proactive" | "overflow";
64
+ };
65
+ /** The per-turn sink. Called synchronously; a throw or a rejected promise is contained by the backend. */
66
+ export type TurnEventSink = (event: TurnEvent) => void;
@@ -0,0 +1 @@
1
+ export {};
@@ -6,7 +6,7 @@
6
6
  * `maskForPrompt` before they leave the process; a secret that cannot be
7
7
  * shown safely withholds the arguments entirely (`unshowable`, review #9).
8
8
  */
9
- import { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_TIMEOUT_REASON, ASK_UNSHOWABLE_REASON, maskForPrompt, } from "#src/permissions/index";
9
+ import { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_PROFILE_REASON, ASK_TIMEOUT_REASON, ASK_UNSHOWABLE_REASON, maskForPrompt, } from "#src/permissions/index";
10
10
  /** Ceiling for the one-line call description an AskResolver receives. */
11
11
  export const MAX_ASK_SUMMARY_CHARS = 200;
12
12
  /**
@@ -53,5 +53,7 @@ export function askDenyReason(decidedBy) {
53
53
  return ASK_CANCELLED_REASON;
54
54
  if (decidedBy === "unshowable")
55
55
  return ASK_UNSHOWABLE_REASON;
56
+ if (decidedBy === "profile")
57
+ return ASK_PROFILE_REASON;
56
58
  return ASK_NO_CHANNEL_REASON;
57
59
  }
@@ -8,9 +8,9 @@
8
8
  * `<root>/child/secret`), so both screens must track it identically to avoid
9
9
  * being fooled by it. This module is the ONE definition of "what does a `cd`
10
10
  * segment resolve to, and how do separators fold that into the running set
11
- * of candidate working directories" -- mirroring `isNaxOwnedWritePath` in
12
- * `nax-owned-writes.ts`, which is the same one-definition-two-callers shape
13
- * for a different seam.
11
+ * of candidate working directories" -- mirroring the `OwnedPathsPolicy` port's
12
+ * `writeRefusal` (`tools/owned-paths.ts`), which is the same
13
+ * one-definition-two-callers shape for a different seam.
14
14
  *
15
15
  * What this module does NOT decide: what happens when a `cd` cannot be
16
16
  * modelled (an option-shaped target, an opaque one, one that fails to
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Read-deny for the host's credential directory and trust-store file in the
3
+ * read tools (S3 spec 6.3). Before S3-2 only the sandbox enforced these, and
4
+ * only for Bash; Read, Glob and Grep enforced root containment alone, so a
5
+ * workdir containing the credential directory exposed it.
6
+ *
7
+ * Paths are compared in canonical form: `realpathSync.native` for a path that
8
+ * exists returns the on-disk casing on a case-insensitive filesystem, where
9
+ * `realOrRaw` (JS `realpathSync`) keeps the caller's casing and would let
10
+ * `.NAX/credentials.json` past a `.nax` prefix check. A path that does not
11
+ * exist cannot be read, so its lexical form is enough.
12
+ */
13
+ import type { ProtectedPathsPolicy } from "./protected-paths.ts";
14
+ /**
15
+ * A predicate over resolved paths, with the credential roots canonicalised
16
+ * once: Glob tests every hit, and a native realpath per root per hit is waste.
17
+ */
18
+ export declare function credentialPathTest(protectedPaths: ProtectedPathsPolicy | undefined): (resolved: string) => boolean;
19
+ /** Why `resolved` may not be read, or undefined when it may. */
20
+ export declare function credentialReadRefusal(protectedPaths: ProtectedPathsPolicy | undefined, resolved: string): string | undefined;
21
+ /** True when searching `dir` would reach the credential directory or the trust store. */
22
+ export declare function containsCredentialPath(protectedPaths: ProtectedPathsPolicy | undefined, dir: string): boolean;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Read-deny for the host's credential directory and trust-store file in the
3
+ * read tools (S3 spec 6.3). Before S3-2 only the sandbox enforced these, and
4
+ * only for Bash; Read, Glob and Grep enforced root containment alone, so a
5
+ * workdir containing the credential directory exposed it.
6
+ *
7
+ * Paths are compared in canonical form: `realpathSync.native` for a path that
8
+ * exists returns the on-disk casing on a case-insensitive filesystem, where
9
+ * `realOrRaw` (JS `realpathSync`) keeps the caller's casing and would let
10
+ * `.NAX/credentials.json` past a `.nax` prefix check. A path that does not
11
+ * exist cannot be read, so its lexical form is enough.
12
+ */
13
+ import { realpathSync } from "node:fs";
14
+ import { sep } from "node:path";
15
+ import { realOrRaw } from "#src/internal/realpath";
16
+ function canonical(p) {
17
+ try {
18
+ return realpathSync.native(p);
19
+ }
20
+ catch {
21
+ return realOrRaw(p);
22
+ }
23
+ }
24
+ function isWithin(base, target) {
25
+ return target === base || target.startsWith(`${base}${sep}`);
26
+ }
27
+ function credentialRoots(protectedPaths) {
28
+ if (protectedPaths === undefined)
29
+ return [];
30
+ return [protectedPaths.credentialDir, protectedPaths.trustStoreFile]
31
+ .filter((p) => p !== undefined)
32
+ .map(canonical);
33
+ }
34
+ const CREDENTIAL_REFUSAL = "is a host credential or trust-store path, which the read tools are refused regardless of grant";
35
+ /**
36
+ * A predicate over resolved paths, with the credential roots canonicalised
37
+ * once: Glob tests every hit, and a native realpath per root per hit is waste.
38
+ */
39
+ export function credentialPathTest(protectedPaths) {
40
+ const roots = credentialRoots(protectedPaths);
41
+ if (roots.length === 0)
42
+ return () => false;
43
+ return (resolved) => {
44
+ const target = canonical(resolved);
45
+ return roots.some((base) => isWithin(base, target));
46
+ };
47
+ }
48
+ /** Why `resolved` may not be read, or undefined when it may. */
49
+ export function credentialReadRefusal(protectedPaths, resolved) {
50
+ return credentialPathTest(protectedPaths)(resolved) ? CREDENTIAL_REFUSAL : undefined;
51
+ }
52
+ /** True when searching `dir` would reach the credential directory or the trust store. */
53
+ export function containsCredentialPath(protectedPaths, dir) {
54
+ const base = canonical(dir);
55
+ return credentialRoots(protectedPaths).some((cred) => isWithin(base, cred));
56
+ }
@@ -14,8 +14,10 @@
14
14
  * `<dir>/ <b1> <b2> ...`, unescaping each quoted basename, and joining each
15
15
  * basename to its directory reconstructs the matched set exactly.
16
16
  */
17
- import { sep } from "node:path";
17
+ import { join, sep } from "node:path";
18
18
  import { getAgentRuntime } from "#src/runtime/index";
19
+ import { credentialPathTest } from "./credential-read-deny.js";
20
+ import { EMPTY_OWNED_PATHS_POLICY } from "./owned-paths.js";
19
21
  import { resolveWithin } from "./policy.js";
20
22
  const MAX_MATCHES = 500;
21
23
  /**
@@ -127,6 +129,9 @@ export const globTool = {
127
129
  if (climbsOut(pattern))
128
130
  return { content: "no matches" };
129
131
  const matches = [];
132
+ // S3 spec 6.3: credential roots are canonicalised once; a native realpath
133
+ // per hit would be waste when a listing covers the credential directory.
134
+ const isCredential = credentialPathTest(ctx.protectedPaths);
130
135
  try {
131
136
  // `absolute: false` is the repo-wide idiom (test-scanner.ts:318,
132
137
  // fragments/store.ts:53, manifest-purge.ts:64) and yields root-relative
@@ -136,7 +141,9 @@ export const globTool = {
136
141
  // a throwing iterator and exercise the catch branch without planting a
137
142
  // malformed pattern on disk.
138
143
  for await (const hit of _globDeps.scan(pattern, { cwd: ctx.root, absolute: false })) {
139
- if (resolveWithin(ctx.root, hit) === null)
144
+ if (resolveWithin(ctx.root, hit, ctx.ownedPaths ?? EMPTY_OWNED_PATHS_POLICY) === null)
145
+ continue;
146
+ if (isCredential(join(ctx.root, hit)))
140
147
  continue;
141
148
  matches.push(hit.split(sep).join("/"));
142
149
  if (matches.length >= MAX_MATCHES)
@@ -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`