praxis-agent 0.46.1 → 0.46.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,6 +153,9 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
153
153
  structured JSON/JSONL, context compaction, tool loops, and bounded execution.
154
154
  - **Built-in tools** — read, write, edit, glob, search, shell, notebook, PDF,
155
155
  image, web, scheduled prompts, workflows, and worktrees.
156
+ - **Shell lifecycle** — foreground Bash allows up to 10 minutes and carries a
157
+ validated final working directory across calls in the same session without
158
+ leaking state across sessions or overriding an explicit `/cd`.
156
159
  - **Permission boundary** — local allow/ask/deny rules, safe and bare modes,
157
160
  searchable scoped-rule creation/removal, local/project/user atomic settings
158
161
  writes, tool-specific Bash/PowerShell/file/notebook/WebFetch/Skill approval
@@ -212,9 +215,13 @@ troubleshooting. Run `praxis --help` for the authoritative command surface.
212
215
  installing it, rejects concurrent updates, and can roll back after an
213
216
  interruption or crash.
214
217
 
215
- Detailed feature status and executable evidence live in the
216
- [parity matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md),
217
- not in this entry-point README.
218
+ Current qualification status and executable evidence live in the
219
+ [Native Fixture Contracts](https://github.com/Forest-Isle/Praxis/blob/main/docs/NATIVE_FIXTURE_CONTRACTS.md)
220
+ and its machine-readable
221
+ [fixture manifest](https://github.com/Forest-Isle/Praxis/blob/main/test/fixtures/manifest.json).
222
+ The [parity matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md)
223
+ and [roadmap](https://github.com/Forest-Isle/Praxis/blob/main/docs/ROADMAP.md) are
224
+ historical clean-room records.
218
225
 
219
226
  ## Native data plane
220
227
 
@@ -234,17 +241,17 @@ only; they do not change Praxis data ownership.
234
241
 
235
242
  ## Documentation
236
243
 
237
- | Need | Document |
238
- | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
239
- | Install and run the first session | [Getting Started](https://github.com/Forest-Isle/Praxis/blob/main/docs/GETTING_STARTED.md) |
240
- | Common commands and environment variables | [CLI Reference](https://github.com/Forest-Isle/Praxis/blob/main/docs/CLI_REFERENCE.md) |
241
- | Find all user and maintainer documentation | [Documentation Index](https://github.com/Forest-Isle/Praxis/blob/main/docs/README.md) |
242
- | Understand module and data-flow boundaries | [Architecture](https://github.com/Forest-Isle/Praxis/blob/main/docs/ARCHITECTURE.md) |
243
- | Review security assumptions | [Threat Model](https://github.com/Forest-Isle/Praxis/blob/main/docs/THREAT_MODEL.md) |
244
- | Check Claude Code parity | [Parity Matrix](https://github.com/Forest-Isle/Praxis/blob/main/docs/PARITY_MATRIX.md) |
245
- | Review interactive TUI design and evidence | [Quiet Operator Spec](https://github.com/Forest-Isle/Praxis/blob/main/docs/TUI_REDESIGN_SPEC.md) |
246
- | Build, test, and contribute | [Contributing](https://github.com/Forest-Isle/Praxis/blob/main/CONTRIBUTING.md) |
247
- | Verify release and supply-chain controls | [Release Contract](https://github.com/Forest-Isle/Praxis/blob/main/docs/RELEASE.md) |
244
+ | Need | Document |
245
+ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
246
+ | Install and run the first session | [Getting Started](https://github.com/Forest-Isle/Praxis/blob/main/docs/GETTING_STARTED.md) |
247
+ | Common commands and environment variables | [CLI Reference](https://github.com/Forest-Isle/Praxis/blob/main/docs/CLI_REFERENCE.md) |
248
+ | Find all user and maintainer documentation | [Documentation Index](https://github.com/Forest-Isle/Praxis/blob/main/docs/README.md) |
249
+ | Understand module and data-flow boundaries | [Architecture](https://github.com/Forest-Isle/Praxis/blob/main/docs/ARCHITECTURE.md) |
250
+ | Review security assumptions | [Threat Model](https://github.com/Forest-Isle/Praxis/blob/main/docs/THREAT_MODEL.md) |
251
+ | Qualify native behavior and evidence | [Native Fixture Contracts](https://github.com/Forest-Isle/Praxis/blob/main/docs/NATIVE_FIXTURE_CONTRACTS.md) |
252
+ | Review interactive TUI design and evidence | [Quiet Operator Spec](https://github.com/Forest-Isle/Praxis/blob/main/docs/TUI_REDESIGN_SPEC.md) |
253
+ | Build, test, and contribute | [Contributing](https://github.com/Forest-Isle/Praxis/blob/main/CONTRIBUTING.md) |
254
+ | Verify release and supply-chain controls | [Release Contract](https://github.com/Forest-Isle/Praxis/blob/main/docs/RELEASE.md) |
248
255
 
249
256
  ## Project boundary
250
257
 
@@ -286,10 +293,13 @@ injected regression protection, plus Quiet Operator input echo `<50 ms` and
286
293
  normal/low-capability full-frame p95 budgets of `<16.7/<33 ms`.
287
294
  `npm run check` also enforces the corresponding source dependency direction.
288
295
  `npm run test:coverage` measures all production code under `src/**` with V8 and
289
- enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines.
290
- `npm run test:core-completion` runs the 56-story #402 audit and reports
291
- implemented, qualified, blocked, deferred, and out-of-scope states separately;
292
- it never treats missing live prerequisites as a pass.
296
+ enforces global floors of 79% statements, 70% branches, 85% functions, and 81% lines,
297
+ and rejects any production runtime module with zero covered statements (while allowing
298
+ type-only modules). `npm run test:fixtures` executes the 67-behavior native contract; 59 behaviors
299
+ are qualified and 8 are explicitly excluded. `npm run verify:fixture-contracts`
300
+ performs the structural check and is part of `npm run check`.
301
+ `npm run test:core-completion` is retained as a compatibility alias for
302
+ `npm run test:fixtures`.
293
303
 
294
304
  Contributions use Conventional Commit pull-request titles and the protected
295
305
  squash-merge workflow. Read
@@ -123,6 +123,11 @@ export interface SessionRunResult {
123
123
  costUsd?: number;
124
124
  modelUsage?: Readonly<Record<string, ModelUsage>>;
125
125
  }
126
+ export interface CwdInspection {
127
+ readonly canonicalTarget: string;
128
+ readonly canonicalCurrentCwd: string;
129
+ readonly sameDirectory: boolean;
130
+ }
126
131
  export interface SideQuestionResult {
127
132
  sessionId: string;
128
133
  text: string;
@@ -290,7 +295,8 @@ export declare class ClaudeSessionService {
290
295
  metadata(sessionId: string): Promise<ClaudeSessionMetadata>;
291
296
  rename(sessionId: string, name: string): Promise<void>;
292
297
  tag(sessionId: string, tag: string): Promise<void>;
293
- changeCwd(sessionId: string | undefined, requestedCwd: string): Promise<string>;
298
+ changeCwd(sessionId: string | undefined, requestedCwd: string, expectedCanonicalTarget?: string): Promise<string>;
299
+ inspectCwd(requestedCwd: string): Promise<CwdInspection>;
294
300
  recordCdUsage(sessionId: string): Promise<void>;
295
301
  approveRecentlyDenied(sessionId: string, display: string): Promise<void>;
296
302
  retryRecentlyDenied(sessionId: string, display: string, signal?: AbortSignal): Promise<SessionRunResult>;
@@ -1806,17 +1806,23 @@ export class ClaudeSessionService {
1806
1806
  const normalized = tag.trim();
1807
1807
  await this.appendNativeCommand(sessionId, `/tag ${normalized}`);
1808
1808
  }
1809
- async changeCwd(sessionId, requestedCwd) {
1809
+ async changeCwd(sessionId, requestedCwd, expectedCanonicalTarget) {
1810
1810
  this.assertWritable();
1811
1811
  const previousCwd = this.activeCwd();
1812
- const expandedCwd = requestedCwd === '~'
1813
- ? homedir()
1814
- : requestedCwd.startsWith('~/')
1815
- ? join(homedir(), requestedCwd.slice(2))
1816
- : requestedCwd;
1817
- const cwd = await realpath(isAbsolute(expandedCwd) ? expandedCwd : join(previousCwd, expandedCwd));
1818
- if (!(await stat(cwd)).isDirectory()) {
1819
- throw new Error(`Not a directory: ${requestedCwd}`);
1812
+ let inspection;
1813
+ try {
1814
+ inspection = await this.inspectCwd(requestedCwd);
1815
+ }
1816
+ catch (error) {
1817
+ if (error instanceof Error &&
1818
+ error.message.includes(' is not a directory.'))
1819
+ throw new Error(`Not a directory: ${requestedCwd}; ${error.message}`);
1820
+ throw error;
1821
+ }
1822
+ const cwd = inspection.canonicalTarget;
1823
+ if (expectedCanonicalTarget !== undefined &&
1824
+ cwd !== expectedCanonicalTarget) {
1825
+ throw new Error(`Directory changed after approval: expected ${expectedCanonicalTarget}, resolved to ${cwd}. Review the target and try again.`);
1820
1826
  }
1821
1827
  if (sessionId && this.options.sessionPersistence !== false) {
1822
1828
  const sourcePaths = this.pathsForCwd(sessionId, this.sessionCwds.get(sessionId) ?? previousCwd);
@@ -1896,6 +1902,33 @@ export class ClaudeSessionService {
1896
1902
  }
1897
1903
  return cwd;
1898
1904
  }
1905
+ async inspectCwd(requestedCwd) {
1906
+ const current = await realpath(this.activeCwd());
1907
+ const expanded = requestedCwd === '~'
1908
+ ? homedir()
1909
+ : requestedCwd.startsWith('~/')
1910
+ ? join(homedir(), requestedCwd.slice(2))
1911
+ : requestedCwd;
1912
+ const resolved = isAbsolute(expanded) ? expanded : join(current, expanded);
1913
+ let target;
1914
+ try {
1915
+ target = await realpath(resolved);
1916
+ }
1917
+ catch (error) {
1918
+ if (error.code === 'ENOENT')
1919
+ throw new Error(`Could not find a directory at ${resolved}.`);
1920
+ throw error;
1921
+ }
1922
+ const info = await stat(target);
1923
+ if (!info.isDirectory()) {
1924
+ throw new Error(`${target} is not a directory. Did you mean ${dirname(target)}?`);
1925
+ }
1926
+ return {
1927
+ canonicalTarget: target,
1928
+ canonicalCurrentCwd: current,
1929
+ sameDirectory: target === current,
1930
+ };
1931
+ }
1899
1932
  async recordCdUsage(sessionId) {
1900
1933
  this.assertWritable();
1901
1934
  const transcript = new NativeSessionTranscript({
@@ -1,4 +1,4 @@
1
- import type { ForkResult, ManualCompactResult, ManualCompactSelection, RewindPoint, SessionForkCheckpoint, SessionRunResult, SessionSummary, SideQuestionForkResult, SideQuestionResult } from '../application/session-service.js';
1
+ import type { CwdInspection, ForkResult, ManualCompactResult, ManualCompactSelection, RewindPoint, SessionForkCheckpoint, SessionRunResult, SessionSummary, SideQuestionForkResult, SideQuestionResult } from '../application/session-service.js';
2
2
  import type { ClaudeSessionCostSnapshot } from '../application/session-cost-tracker.js';
3
3
  import type { ModelImage, ModelToolCall, PermissionApproval, PermissionDecision, RuntimeEventSink } from '../core/runtime.js';
4
4
  import type { ActiveTurnInputCommandResult } from '../core/active-turn-input.js';
@@ -46,7 +46,8 @@ interface InteractiveSessionCommands {
46
46
  compact?(sessionId: string, signal?: AbortSignal, selection?: ManualCompactSelection): Promise<ManualCompactResult>;
47
47
  rewindFiles?(sessionId: string, userMessageId: string): Promise<void>;
48
48
  rewindPoints?(sessionId: string): Promise<RewindPoint[]>;
49
- changeCwd?(sessionId: string | undefined, cwd: string): Promise<string>;
49
+ changeCwd?(sessionId: string | undefined, cwd: string, expectedCanonicalTarget?: string): Promise<string>;
50
+ inspectCwd?(cwd: string): Promise<CwdInspection>;
50
51
  notify?(sessionId: string | undefined, message: string, notificationType: string, title?: string): void;
51
52
  recordCdUsage?(sessionId: string): Promise<void>;
52
53
  approveRecentlyDenied?(sessionId: string, display: string): Promise<void>;
@@ -610,6 +610,35 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
610
610
  const planApprovalRef = useRef(null);
611
611
  const [planApprovalSelection, setPlanApprovalSelection] = useState(0);
612
612
  const [planApprovalFeedbackMode, setPlanApprovalFeedbackMode] = useState(false);
613
+ const [cdTrust, setCdTrust] = useState(null);
614
+ const [cdTrustSelection, setCdTrustSelection] = useState(0);
615
+ const cdTrustRef = useRef(null);
616
+ const trustedCwdsBySessionRef = useRef(new Map());
617
+ const trustedCwdSessionRef = useRef(sessionId);
618
+ useEffect(() => {
619
+ const previousSessionId = trustedCwdSessionRef.current;
620
+ if (previousSessionId === sessionId)
621
+ return;
622
+ if (previousSessionId === null && sessionId !== null) {
623
+ const pendingSessionTrust = trustedCwdsBySessionRef.current.get(null);
624
+ if (pendingSessionTrust)
625
+ trustedCwdsBySessionRef.current.set(sessionId, pendingSessionTrust);
626
+ trustedCwdsBySessionRef.current.delete(null);
627
+ }
628
+ else if (sessionId === null) {
629
+ trustedCwdsBySessionRef.current.delete(null);
630
+ }
631
+ trustedCwdSessionRef.current = sessionId;
632
+ }, [sessionId]);
633
+ const trustedCwds = () => {
634
+ const key = sessionIdRef.current;
635
+ const existing = trustedCwdsBySessionRef.current.get(key);
636
+ if (existing)
637
+ return existing;
638
+ const created = new Set();
639
+ trustedCwdsBySessionRef.current.set(key, created);
640
+ return created;
641
+ };
613
642
  const serviceRef = useRef(null);
614
643
  const serviceCreationRef = useRef(undefined);
615
644
  const onCleanupRef = useRef(onCleanup);
@@ -868,6 +897,13 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
868
897
  answer: input,
869
898
  })
870
899
  : null, [question, input]);
900
+ const cdTrustSurface = useMemo(() => cdTrust
901
+ ? projectTuiDecisionSurface({
902
+ kind: 'cd-trust',
903
+ canonicalPath: cdTrust.canonicalPath,
904
+ selectedIndex: cdTrustSelection,
905
+ })
906
+ : null, [cdTrust, cdTrustSelection]);
871
907
  const elicitationSurface = useMemo(() => elicitation === null
872
908
  ? null
873
909
  : projectTuiElicitationSurface({
@@ -1129,16 +1165,18 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1129
1165
  }
1130
1166
  : planApproval !== null && planDecisionSurface !== null
1131
1167
  ? { priority: planDecisionSurface }
1132
- : question !== null && questionDecisionSurface !== null
1133
- ? { priority: questionDecisionSurface }
1134
- : elicitation !== null && elicitationSurface !== null
1135
- ? {
1136
- priority: {
1137
- kind: 'elicitation',
1138
- surface: elicitationSurface,
1139
- },
1140
- }
1141
- : {}),
1168
+ : cdTrust !== null && cdTrustSurface !== null
1169
+ ? { priority: cdTrustSurface }
1170
+ : question !== null && questionDecisionSurface !== null
1171
+ ? { priority: questionDecisionSurface }
1172
+ : elicitation !== null && elicitationSurface !== null
1173
+ ? {
1174
+ priority: {
1175
+ kind: 'elicitation',
1176
+ surface: elicitationSurface,
1177
+ },
1178
+ }
1179
+ : {}),
1142
1180
  ...(secondarySurface === undefined
1143
1181
  ? {}
1144
1182
  : { secondary: secondarySurface }),
@@ -1156,6 +1194,8 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1156
1194
  permission,
1157
1195
  permissionPrioritySurface,
1158
1196
  planApproval,
1197
+ cdTrust,
1198
+ cdTrustSurface,
1159
1199
  question,
1160
1200
  planDecisionSurface,
1161
1201
  questionDecisionSurface,
@@ -2739,23 +2779,58 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2739
2779
  onTurnChange?.(loading);
2740
2780
  void loading.finally(() => onTurnChange?.(null));
2741
2781
  };
2742
- const changeWorkingDirectory = (requestedCwd) => {
2782
+ const changeWorkingDirectory = (requestedCwd, authorization = {
2783
+ kind: 'unapproved',
2784
+ }) => {
2743
2785
  const changing = (async () => {
2744
- setBusy(true);
2745
- setStatus('changing directory');
2746
2786
  try {
2747
2787
  const commands = await service();
2748
2788
  if (!commands.changeCwd) {
2749
2789
  throw new Error('This interactive service cannot change working directories.');
2750
2790
  }
2751
- const cwd = await commands.changeCwd(sessionId ?? undefined, requestedCwd);
2791
+ if (!commands.inspectCwd) {
2792
+ throw new Error('This interactive service cannot inspect or change working directories.');
2793
+ }
2794
+ const inspection = await commands.inspectCwd(requestedCwd);
2795
+ if (authorization.kind === 'approved' &&
2796
+ inspection.canonicalTarget !== authorization.canonicalTarget) {
2797
+ throw new Error(`Directory changed after approval: expected ${authorization.canonicalTarget}, resolved to ${inspection.canonicalTarget}. Review the target and try again.`);
2798
+ }
2799
+ const activeSessionTrust = trustedCwds();
2800
+ activeSessionTrust.add(inspection.canonicalCurrentCwd);
2801
+ if (inspection.sameDirectory) {
2802
+ append({
2803
+ kind: 'local-result',
2804
+ text: `Already in ${inspection.canonicalTarget}.`,
2805
+ });
2806
+ return;
2807
+ }
2808
+ if (authorization.kind === 'unapproved' &&
2809
+ !activeSessionTrust.has(inspection.canonicalTarget)) {
2810
+ const pending = {
2811
+ canonicalPath: inspection.canonicalTarget,
2812
+ };
2813
+ cdTrustRef.current = pending;
2814
+ setCdTrust(pending);
2815
+ return;
2816
+ }
2817
+ setBusy(true);
2818
+ setStatus('changing directory');
2819
+ const cwd = await commands.changeCwd(sessionId ?? undefined, inspection.canonicalTarget, inspection.canonicalTarget);
2820
+ activeSessionTrust.add(cwd);
2752
2821
  runtimeCwdRef.current = cwd;
2753
2822
  setRuntimeCwd(cwd);
2754
2823
  await retireService();
2755
2824
  append({ kind: 'local-result', text: `Moved to ${cwd}` });
2756
2825
  }
2757
2826
  catch (error) {
2758
- warn(error);
2827
+ const message = error instanceof Error ? error.message : String(error);
2828
+ if (authorization.kind === 'approved')
2829
+ trustedCwds().delete(authorization.canonicalTarget);
2830
+ if (/^(Could not find a directory at .*\.|.* is not a directory\. Did you mean .+\?)$/u.test(message))
2831
+ append({ kind: 'local-result', text: message });
2832
+ else
2833
+ warn(error);
2759
2834
  }
2760
2835
  finally {
2761
2836
  setBusy(false);
@@ -3688,6 +3763,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3688
3763
  case 'command-palette':
3689
3764
  setCommandPaletteOpen(false);
3690
3765
  break;
3766
+ case 'cd-trust':
3767
+ cdTrustRef.current = null;
3768
+ setCdTrust(null);
3769
+ setCdTrustSelection(0);
3770
+ break;
3691
3771
  default: {
3692
3772
  const unhandledTarget = effect.target;
3693
3773
  return unhandledTarget;
@@ -3738,7 +3818,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3738
3818
  : ['Select']
3739
3819
  : commandPaletteVisible || filePickerVisible
3740
3820
  ? ['Autocomplete', 'Chat']
3741
- : permission || planApproval
3821
+ : permission || planApproval || cdTrust
3742
3822
  ? ['Confirmation']
3743
3823
  : selectingSession
3744
3824
  ? ['Select']
@@ -3809,6 +3889,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3809
3889
  pendingPrefix: hasPendingPrefix,
3810
3890
  permission: Boolean(permission),
3811
3891
  planApproval: Boolean(planApproval),
3892
+ cdTrust: Boolean(cdTrust),
3812
3893
  question: Boolean(question),
3813
3894
  elicitation: elicitation
3814
3895
  ? elicitationUrlWaiting
@@ -4063,6 +4144,35 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
4063
4144
  }
4064
4145
  return;
4065
4146
  }
4147
+ if (cdTrust) {
4148
+ const resolveCdTrust = (selectedIndex) => {
4149
+ const pending = cdTrustRef.current;
4150
+ cdTrustRef.current = null;
4151
+ setCdTrust(null);
4152
+ setCdTrustSelection(0);
4153
+ if (!pending || selectedIndex !== 1)
4154
+ return;
4155
+ changeWorkingDirectory(pending.canonicalPath, {
4156
+ kind: 'approved',
4157
+ canonicalTarget: pending.canonicalPath,
4158
+ });
4159
+ };
4160
+ if (key.upArrow) {
4161
+ setCdTrustSelection((current) => Math.max(0, current - 1));
4162
+ }
4163
+ else if (key.downArrow) {
4164
+ setCdTrustSelection((current) => Math.min(1, current + 1));
4165
+ }
4166
+ else if (lower === 'y')
4167
+ resolveCdTrust(1);
4168
+ else if (lower === 'n')
4169
+ resolveCdTrust(0);
4170
+ else if (/^[12]$/u.test(value))
4171
+ resolveCdTrust(Number(value) - 1);
4172
+ else if (key.return)
4173
+ resolveCdTrust(cdTrustSelection);
4174
+ return;
4175
+ }
4066
4176
  if (planApproval) {
4067
4177
  const elevatedMode = runtimeSettingsRef.current
4068
4178
  .useAutoModeDuringPlan
@@ -32,6 +32,10 @@ export type TuiDecisionSurfaceInput = {
32
32
  readonly questions: readonly ClaudeQuestion[];
33
33
  readonly questionIndex: number;
34
34
  readonly answer: string;
35
+ } | {
36
+ readonly kind: 'cd-trust';
37
+ readonly canonicalPath: string;
38
+ readonly selectedIndex: number;
35
39
  };
36
40
  export interface TuiPlanApprovalSurfaceModel {
37
41
  readonly kind: 'plan-approval';
@@ -66,6 +70,19 @@ export interface TuiQuestionSurfaceModel {
66
70
  readonly cancellation: TuiDecisionSurfaceCancellation;
67
71
  readonly emptyState?: string;
68
72
  }
69
- export type TuiDecisionSurfaceModel = TuiPlanApprovalSurfaceModel | TuiQuestionSurfaceModel;
73
+ export interface TuiCdTrustSurfaceModel {
74
+ readonly kind: 'cd-trust';
75
+ readonly heading: 'Moving to a new directory:';
76
+ readonly canonicalPath: string;
77
+ readonly explanation: "This session hasn't worked here before. Is this a directory you created or one you trust?";
78
+ readonly scope: 'Praxis can read, edit, and execute files in this directory.';
79
+ readonly securityGuide: 'Security guide: https://code.claude.com/docs/en/security';
80
+ readonly options: readonly TuiDecisionSurfaceOption[];
81
+ readonly selectedIndex: number;
82
+ readonly range: TuiDecisionSurfaceRange;
83
+ readonly actions: readonly TuiDecisionSurfaceAction[];
84
+ readonly cancellation: TuiDecisionSurfaceCancellation;
85
+ }
86
+ export type TuiDecisionSurfaceModel = TuiPlanApprovalSurfaceModel | TuiQuestionSurfaceModel | TuiCdTrustSurfaceModel;
70
87
  export declare function projectTuiDecisionSurface(input: TuiDecisionSurfaceInput): TuiDecisionSurfaceModel;
71
88
  //# sourceMappingURL=decision-surface-model.d.ts.map
@@ -100,6 +100,32 @@ export function projectTuiDecisionSurface(input) {
100
100
  cancellation,
101
101
  };
102
102
  }
103
+ if (input.kind === 'cd-trust') {
104
+ const selectedIndex = normalizeIndex(input.selectedIndex, 2);
105
+ const options = projectedOptions([{ label: 'No, stay put' }, { label: 'Yes, move here' }], selectedIndex);
106
+ return {
107
+ kind: 'cd-trust',
108
+ heading: 'Moving to a new directory:',
109
+ canonicalPath: input.canonicalPath,
110
+ explanation: "This session hasn't worked here before. Is this a directory you created or one you trust?",
111
+ scope: 'Praxis can read, edit, and execute files in this directory.',
112
+ securityGuide: 'Security guide: https://code.claude.com/docs/en/security',
113
+ options,
114
+ selectedIndex,
115
+ range: range(options.length),
116
+ actions: [
117
+ {
118
+ visualLabel: 'Enter to confirm',
119
+ screenReaderLabel: 'Enter to confirm',
120
+ },
121
+ { screenReaderLabel: 'Use up and down arrows to change selection' },
122
+ { screenReaderLabel: 'Press 1 or 2 to choose directly' },
123
+ { screenReaderLabel: 'Press y to move here' },
124
+ { screenReaderLabel: 'Press n to stay put' },
125
+ ],
126
+ cancellation,
127
+ };
128
+ }
103
129
  const count = input.questions.length;
104
130
  const questionIndex = normalizeIndex(input.questionIndex, count);
105
131
  if (count === 0)
@@ -31,8 +31,11 @@ function PlanSurface({ model, screenReader, }) {
31
31
  function QuestionSurface({ model, screenReader, }) {
32
32
  return (_jsxs(DialogFrame, { title: model.heading, screenReader: screenReader, children: [_jsx(Text, { children: model.progress }), model.emptyState ? _jsx(Text, { dimColor: true, children: model.emptyState }) : null, model.options.map((option) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [option.index, ". ", option.label, option.description ? ` — ${option.description}` : ''] }), option.preview ? _jsx(Text, { dimColor: true, children: option.preview }) : null] }, `${option.index}-${option.label}`))), _jsxs(Text, { children: [screenReader ? 'Current answer: ' : '› ', model.answer || (screenReader ? '(empty)' : '')] }), _jsx(Footer, { ...model, screenReader: screenReader })] }));
33
33
  }
34
+ function CdTrustSurface({ model, screenReader, }) {
35
+ return (_jsxs(DialogFrame, { title: model.heading, screenReader: screenReader, children: [_jsx(Text, { children: model.canonicalPath }), _jsx(Text, { dimColor: true, children: model.explanation }), _jsx(Text, { dimColor: true, children: model.scope }), _jsx(Text, { dimColor: true, children: model.securityGuide }), model.options.map((option) => (_jsx(Option, { option: option, screenReader: screenReader }, option.index))), _jsx(Footer, { ...model, screenReader: screenReader })] }));
36
+ }
34
37
  export function DecisionSurface({ model, width, screenReader, }) {
35
- const content = model.kind === 'plan-approval' ? (_jsx(PlanSurface, { model: model, screenReader: screenReader })) : (_jsx(QuestionSurface, { model: model, screenReader: screenReader }));
38
+ const content = model.kind === 'plan-approval' ? (_jsx(PlanSurface, { model: model, screenReader: screenReader })) : model.kind === 'question' ? (_jsx(QuestionSurface, { model: model, screenReader: screenReader })) : (_jsx(CdTrustSurface, { model: model, screenReader: screenReader }));
36
39
  const normalizedWidth = Number.isFinite(width)
37
40
  ? Math.min(100, Math.max(1, Math.trunc(width)))
38
41
  : 100;
@@ -1,2 +1,12 @@
1
- export declare function openTuiUrl(url: string): Promise<void>;
1
+ type OpenUrlOptions = {
2
+ timeout: number;
3
+ shell: false;
4
+ };
5
+ type OpenUrlExecutor = (command: string, args: string[], options: OpenUrlOptions, callback: (error?: Error | null) => void) => unknown;
6
+ type OpenUrlDependencies = {
7
+ platform?: NodeJS.Platform;
8
+ execFile?: OpenUrlExecutor;
9
+ };
10
+ export declare function openTuiUrl(url: string, dependencies?: OpenUrlDependencies): Promise<void>;
11
+ export {};
2
12
  //# sourceMappingURL=open-url.d.ts.map
@@ -1,13 +1,33 @@
1
1
  import { execFile } from 'node:child_process';
2
- export async function openTuiUrl(url) {
3
- const command = process.platform === 'darwin'
2
+ const defaultExecFile = (command, args, options, callback) => execFile(command, args, options, callback);
3
+ export async function openTuiUrl(url, dependencies = {}) {
4
+ let parsedUrl;
5
+ try {
6
+ parsedUrl = new URL(url);
7
+ }
8
+ catch {
9
+ throw new Error('TUI URL must be an absolute HTTP(S) URL');
10
+ }
11
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
12
+ throw new Error('TUI URL must be an absolute HTTP(S) URL');
13
+ const canonicalUrl = parsedUrl.href;
14
+ const platform = dependencies.platform ?? process.platform;
15
+ const command = platform === 'darwin'
4
16
  ? 'open'
5
- : process.platform === 'win32'
6
- ? 'cmd'
17
+ : platform === 'win32'
18
+ ? 'rundll32.exe'
7
19
  : 'xdg-open';
8
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
9
- await new Promise((resolvePromise) => {
10
- execFile(command, args, { timeout: 10_000 }, () => resolvePromise());
20
+ const args = platform === 'win32'
21
+ ? ['url.dll,FileProtocolHandler', canonicalUrl]
22
+ : [canonicalUrl];
23
+ const executor = dependencies.execFile ?? defaultExecFile;
24
+ await new Promise((resolvePromise, rejectPromise) => {
25
+ try {
26
+ executor(command, args, { timeout: 10_000, shell: false }, (error) => error == null ? resolvePromise() : rejectPromise(error));
27
+ }
28
+ catch (error) {
29
+ rejectPromise(error);
30
+ }
11
31
  });
12
32
  }
13
33
  //# sourceMappingURL=open-url.js.map
@@ -176,6 +176,17 @@ function decision(surface, density, sr) {
176
176
  explicitFooter('quiet:plan:footer', '↑/↓ select Enter confirm Esc cancel', 'Use up and down arrows to select. Press Enter to confirm. Press Escape to cancel.'),
177
177
  ];
178
178
  }
179
+ if (surface.kind === 'cd-trust') {
180
+ out.push(row('quiet:cd-trust:path', surface.canonicalPath, 'body'));
181
+ out.push(row('quiet:cd-trust:explanation', surface.explanation, 'muted'));
182
+ out.push(row('quiet:cd-trust:scope', surface.scope, 'muted'));
183
+ out.push(row('quiet:cd-trust:security-guide', surface.securityGuide, 'muted'));
184
+ out.push(...optionsRows(surface.options, 'quiet:cd-trust:option', density, sr));
185
+ return [
186
+ ...out,
187
+ explicitFooter('quiet:cd-trust:footer', '↑/↓ select Enter confirm Esc cancel', 'Use up and down arrows to select. Press Enter to confirm. Press Escape to cancel.'),
188
+ ];
189
+ }
179
190
  if (surface.progress)
180
191
  out.push(row('quiet:question:progress', surface.progress, 'muted'));
181
192
  if (surface.question)
@@ -291,7 +302,9 @@ export function projectQuietChoiceRows(surface, options) {
291
302
  row('quiet:exit:heading', 'Exit Praxis?', 'heading'),
292
303
  explicitFooter('quiet:exit:footer', 'Enter confirm Esc cancel', 'Press Enter to confirm. Press Escape to cancel.'),
293
304
  ];
294
- if (surface.kind === 'plan-approval' || surface.kind === 'question')
305
+ if (surface.kind === 'plan-approval' ||
306
+ surface.kind === 'question' ||
307
+ surface.kind === 'cd-trust')
295
308
  return decision(surface, options.density, sr);
296
309
  if (surface.kind === 'session-picker' ||
297
310
  surface.kind === 'command-palette' ||
@@ -1,8 +1,16 @@
1
1
  import type { SandboxDependencyCheck } from '@anthropic-ai/sandbox-runtime';
2
2
  import type { JsonResource } from '../../core/resources.js';
3
+ import { type ClaudeSandboxPlatform } from '../../sandbox/claude-sandbox-runtime.js';
3
4
  import { type ClaudeSandboxSettings } from '../../sandbox/claude-sandbox-settings.js';
4
5
  export type TuiSandboxMode = 'auto-allow' | 'regular' | 'disabled';
5
6
  export type TuiSandboxTab = 'mode' | 'dependencies' | 'overrides' | 'config';
7
+ export interface TuiSandboxRuntime {
8
+ initialize(settings: ClaudeSandboxSettings): Promise<void>;
9
+ unavailableReason(settings: ClaudeSandboxSettings): string | undefined;
10
+ platformName(): ClaudeSandboxPlatform;
11
+ dependencyCheck(settings: ClaudeSandboxSettings): SandboxDependencyCheck;
12
+ isSupportedPlatform(): boolean;
13
+ }
6
14
  export interface TuiSandboxSnapshot {
7
15
  settings: ClaudeSandboxSettings;
8
16
  dependencies: SandboxDependencyCheck;
@@ -22,11 +30,12 @@ export interface TuiSandboxStore {
22
30
  }>;
23
31
  }
24
32
  export declare function linuxGlobPatternWarnings(resources: readonly JsonResource[], platform: 'macos' | 'linux' | 'windows' | 'wsl'): string[];
25
- export declare function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories, environment, }: {
33
+ export declare function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories, environment, runtime, }: {
26
34
  configRoot: string;
27
35
  cwd: string;
28
36
  homeDirectory: string;
29
37
  additionalDirectories?: readonly string[];
30
38
  environment?: NodeJS.ProcessEnv;
39
+ runtime?: TuiSandboxRuntime;
31
40
  }): TuiSandboxStore;
32
41
  //# sourceMappingURL=sandbox-settings.d.ts.map
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join, relative } from 'node:path';
3
3
  import { writeFileAtomically } from '../../platform/atomic-write.js';
4
4
  import { loadNativeSharedResources } from '../../persistence/native-resources.js';
5
- import { claudeSandboxRuntime } from '../../sandbox/claude-sandbox-runtime.js';
5
+ import { claudeSandboxRuntime, } from '../../sandbox/claude-sandbox-runtime.js';
6
6
  import { nativeSandboxTempDirectory, loadClaudeSandboxSettings, } from '../../sandbox/claude-sandbox-settings.js';
7
7
  function isRecord(value) {
8
8
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -75,7 +75,7 @@ export function linuxGlobPatternWarnings(resources, platform) {
75
75
  }
76
76
  return [...new Set(warnings)];
77
77
  }
78
- export function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories = [], environment = process.env, }) {
78
+ export function createTuiSandboxStore({ configRoot, cwd, homeDirectory, additionalDirectories = [], environment = process.env, runtime = claudeSandboxRuntime, }) {
79
79
  const localSettingsPath = join(cwd, '.praxis', 'settings.local.json');
80
80
  const load = async () => {
81
81
  const resources = (await loadNativeSharedResources({ root: configRoot, cwd })).settings;
@@ -87,13 +87,13 @@ export function createTuiSandboxStore({ configRoot, cwd, homeDirectory, addition
87
87
  additionalDirectories,
88
88
  tempDirectory: nativeSandboxTempDirectory(environment),
89
89
  });
90
- await claudeSandboxRuntime.initialize(settings);
91
- const unavailableReason = claudeSandboxRuntime.unavailableReason(settings);
92
- const platform = claudeSandboxRuntime.platformName();
90
+ await runtime.initialize(settings);
91
+ const unavailableReason = runtime.unavailableReason(settings);
92
+ const platform = runtime.platformName();
93
93
  return {
94
94
  settings,
95
- dependencies: claudeSandboxRuntime.dependencyCheck(settings),
96
- supported: claudeSandboxRuntime.isSupportedPlatform(),
95
+ dependencies: runtime.dependencyCheck(settings),
96
+ supported: runtime.isSupportedPlatform(),
97
97
  platform,
98
98
  globPatternWarnings: linuxGlobPatternWarnings(resources, platform),
99
99
  ...(unavailableReason ? { unavailableReason } : {}),
@@ -7,6 +7,7 @@ export interface TuiFocusProjectionInput {
7
7
  readonly pendingPrefix: boolean;
8
8
  readonly permission: boolean;
9
9
  readonly planApproval: boolean;
10
+ readonly cdTrust?: boolean;
10
11
  readonly question: boolean;
11
12
  readonly elicitation: 'plain' | 'url-waiting' | 'expanded-options' | false;
12
13
  readonly selectingSession: boolean;