zob-harness 0.16.0 → 0.17.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.
@@ -1129,6 +1129,46 @@
1129
1129
  ],
1130
1130
  "noShipNotes": "Readiness check only."
1131
1131
  },
1132
+ {
1133
+ "name": "zob_read_full",
1134
+ "family": "files",
1135
+ "modes": [
1136
+ "explore",
1137
+ "plan",
1138
+ "implement",
1139
+ "oracle",
1140
+ "factory",
1141
+ "orchestrator"
1142
+ ],
1143
+ "skillRefs": [
1144
+ ".pi/skills/zob-file-read/SKILL.md"
1145
+ ],
1146
+ "docRefs": [
1147
+ ".pi/extensions/zob-harness/src/AGENTS.md",
1148
+ "README.md"
1149
+ ],
1150
+ "noShipNotes": "Whole-file read only; returns content iff path passes secret/generated-path safety, the file is a readable utf8 regular file under the hard byte ceiling, and (when the context window is known) estimated tokens fit the context headroom (allowedTokens = min(availableTokens*(1-safetyMargin/100), contextWindow*0.5)); otherwise blocks with pagination guidance and no content. Never reads .env/~/.ssh/~/.aws/*.pem/*.key (including via symlinks, blocked as symlink_resolves_to_zero_access after realpath canonicalization), node_modules, dist, build, .pi/sessions, .pi/agent-sessions; v1 utf8 only (non-utf8 -> binary_not_supported); telemetry is body-free (details carry hashes/counts only, bodyStored:false); does not bypass the file-tool preflight or other safety gates."
1151
+ },
1152
+ {
1153
+ "name": "zob_receive_full",
1154
+ "family": "files",
1155
+ "modes": [
1156
+ "explore",
1157
+ "plan",
1158
+ "implement",
1159
+ "oracle",
1160
+ "factory",
1161
+ "orchestrator"
1162
+ ],
1163
+ "skillRefs": [
1164
+ ".pi/skills/zob-file-read/SKILL.md"
1165
+ ],
1166
+ "docRefs": [
1167
+ ".pi/extensions/zob-harness/src/AGENTS.md",
1168
+ "README.md"
1169
+ ],
1170
+ "noShipNotes": "Long-response receive only; returns content iff exactly one source is provided (path or run_id) and the resolved target passes the shared file-tool preflight path/secret safety (lexical zero-access + realpath symlink check inherited from zob_read_full's runFullRead), is a readable utf8 regular file under the hard byte ceiling, and (when the context window is known) estimated tokens fit the context headroom (allowedTokens = min(availableTokens*(1-safetyMargin/100), contextWindow*0.5)); otherwise blocks with recovery guidance and no content. run_id resolves ONLY to a run's persisted report artifact under reports/factory-runs|orchestrations|chains/<runId>/ (default final-report.md, or a named single-basename artifact) — raw child output bodies are NOT persisted (body-free harness) and cannot be retrieved; run_id and artifact are validated path-safe (alphanumeric/.//- only, no slashes or ..) before any resolution, refusing run_id_unsafe/artifact_unsafe/run_not_found/artifact_not_found. max_bytes tightens the 2MB ceiling only (cannot enlarge). Telemetry is body-free (details carry hashes/counts/source metadata only, bodyStored:false); does not bypass the file-tool preflight or other safety gates."
1171
+ },
1132
1172
  {
1133
1173
  "name": "zob_context_validate_scope",
1134
1174
  "family": "context",
@@ -688,6 +688,52 @@ export type {
688
688
  FileToolPreflightVerdict,
689
689
  } from "./src/domains/governance/file-tool-preflight.js";
690
690
 
691
+ export {
692
+ FULL_READ_DEFAULT_IO,
693
+ FULL_READ_DEFAULT_POLICY,
694
+ FULL_READ_SCHEMA,
695
+ classifyPathForbiddenGenerated,
696
+ classifyPathSecret,
697
+ evaluateFullRead,
698
+ fullReadBodyFreeViolations,
699
+ runFullRead,
700
+ } from "./src/domains/files/full-read.js";
701
+ export type {
702
+ FullReadContextUsage,
703
+ FullReadDecision,
704
+ FullReadDetails,
705
+ FullReadEncoding,
706
+ FullReadEvaluation,
707
+ FullReadFacts,
708
+ FullReadIo,
709
+ FullReadPolicy,
710
+ FullReadReasonCode,
711
+ FullReadRunInput,
712
+ FullReadRunResult,
713
+ FullReadStat,
714
+ } from "./src/domains/files/full-read.js";
715
+
716
+ export {
717
+ DEFAULT_RUN_ARTIFACT,
718
+ RESPONSE_RECEIVE_SCHEMA,
719
+ RUN_ARTIFACT_DIRS,
720
+ isPathSafeArtifactName,
721
+ isPathSafeRunId,
722
+ receiveFullResponse,
723
+ resolveRunArtifact,
724
+ responseReceiveBodyFreeViolations,
725
+ runDirRelative,
726
+ } from "./src/domains/files/response-receive.js";
727
+ export type {
728
+ ResponseReceiveDetails,
729
+ ResponseReceiveInput,
730
+ ResponseReceiveReasonCode,
731
+ ResponseReceiveResult,
732
+ ResponseReceiveRunType,
733
+ ResponseReceiveSource,
734
+ RunArtifactResolution,
735
+ } from "./src/domains/files/response-receive.js";
736
+
691
737
  export {
692
738
  validateSandboxWritePlanInputs,
693
739
  runSandboxWritePlan,
@@ -59,6 +59,7 @@ export const ZOB_DELEGATION_READ_TOOLS = ["zob_delegation_catalog", "get_delegat
59
59
  export const ZOB_MISSION_CONTROL_READ_TOOLS = ["zob_coms_readiness", "zob_mission_control_snapshot"] as const;
60
60
  export const ZOB_MISSION_CONTROL_PROPOSAL_TOOLS = ["zob_mission_control_propose_command"] as const;
61
61
  export const ZOB_CONTEXT_READ_TOOLS = ["zob_context_search", "zob_context_readiness", "zob_context_validate_scope"] as const;
62
+ export const ZOB_FILES_READ_TOOLS = ["zob_read_full", "zob_receive_full"] as const;
62
63
  export const ZOB_CONTEXT_PROPOSAL_TOOLS = ["zob_context_writeback_proposal"] as const;
63
64
  export const ZOB_COMPUTE_READ_TOOLS = ["zob_compute_preview", "zob_compute_resolve_profile", "zob_compute_plan_workflow", "zob_compute_validate_profile"] as const;
64
65
  export const ZOB_COMPUTE_REPORT_TOOLS = ["zob_compute_write_profile_reports"] as const;
@@ -70,12 +71,12 @@ export const ZOB_AUTONOMOUS_READ_TOOLS = ["zob_autonomous_validate_run", "zob_au
70
71
  export const ZOB_AUTONOMOUS_FACTORY_TOOLS = ["zob_autonomous_dry_run", "zob_autonomous_readonly_smoke"] as const;
71
72
 
72
73
  export const MODE_TOOLS: Record<ModeName, string[]> = {
73
- explore: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
74
- plan: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
75
- implement: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
76
- oracle: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
77
- orchestrator: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS],
78
- factory: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_AUTONOMOUS_FACTORY_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
74
+ explore: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_FILES_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
75
+ plan: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_FILES_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
76
+ implement: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_FILES_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
77
+ oracle: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_FILES_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
78
+ orchestrator: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_FILES_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS],
79
+ factory: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_AUTONOMOUS_FACTORY_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_FILES_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
79
80
  // Vanilla is handled specially by applyMode: all currently available Pi tools are enabled.
80
81
  vanilla: [],
81
82
  };
@@ -0,0 +1,488 @@
1
+ import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { DEFAULT_RULES } from "../../core/constants.js";
5
+ import { sha256Hex } from "../../core/utils/hashing.js";
6
+ import { pathMatches } from "../../core/utils/paths.js";
7
+
8
+ /**
9
+ * `full-read.ts` is the PURE context-window-aware decision module for the future
10
+ * `zob_read_full` tool. It decides whether an entire file may be returned in one
11
+ * call given the live model context window, refusing with pagination guidance
12
+ * (block `exceeds_context_budget` / `exceeds_hard_ceiling`) when there is not
13
+ * enough headroom. All decision logic is pure over an injected `io` + `usage`;
14
+ * the only `node:fs` use lives in `FULL_READ_DEFAULT_IO`.
15
+ *
16
+ * Telemetry is body-free: `FullReadDetails` never carries file content, only
17
+ * hashes, byte/token counts, and policy echoes (`bodyStored: false`).
18
+ */
19
+
20
+ export type FullReadDecision = "pass" | "block" | "observe";
21
+
22
+ export type FullReadReasonCode =
23
+ | "full_read_pass"
24
+ | "context_unknown_fallback_pass"
25
+ | "path_not_found"
26
+ | "path_not_file"
27
+ | "path_secret_rejected"
28
+ | "path_forbidden_generated"
29
+ | "path_not_readable"
30
+ | "inspection_failed"
31
+ | "binary_not_supported"
32
+ | "exceeds_context_budget"
33
+ | "exceeds_hard_ceiling"
34
+ | "symlink_resolves_to_zero_access";
35
+
36
+ export type FullReadEncoding = "utf8";
37
+
38
+ export const FULL_READ_SCHEMA = "zob.full-read.v1";
39
+
40
+ export interface FullReadContextUsage {
41
+ tokens?: number;
42
+ contextWindow?: number;
43
+ percent?: number;
44
+ }
45
+
46
+ export interface FullReadPolicy {
47
+ zeroAccessPaths: string[];
48
+ forbiddenGeneratedPaths: string[];
49
+ safetyMarginPercent: number;
50
+ maxAllowedContextFraction: number;
51
+ hardCeilingBytesDefault: number;
52
+ }
53
+
54
+ export const FULL_READ_DEFAULT_POLICY: FullReadPolicy = {
55
+ zeroAccessPaths: [...DEFAULT_RULES.zeroAccessPaths],
56
+ forbiddenGeneratedPaths: ["node_modules/", "dist/", "build/", ".pi/sessions/", ".pi/agent-sessions/"],
57
+ safetyMarginPercent: 30,
58
+ maxAllowedContextFraction: 0.5,
59
+ hardCeilingBytesDefault: 2 * 1024 * 1024,
60
+ };
61
+
62
+ export interface FullReadStat {
63
+ size: number;
64
+ isFile(): boolean;
65
+ isDirectory(): boolean;
66
+ }
67
+
68
+ export interface FullReadIo {
69
+ stat(path: string): FullReadStat;
70
+ accessReadable(path: string): void;
71
+ readFile(path: string): string;
72
+ realpath(path: string): string;
73
+ }
74
+
75
+ export interface FullReadFacts {
76
+ pathKind: "file" | "directory" | "other" | "missing";
77
+ isSecret: boolean;
78
+ isForbiddenGenerated: boolean;
79
+ binaryRequested: boolean;
80
+ readable: boolean;
81
+ byteSize: number;
82
+ estimatedTokens: number;
83
+ usage: FullReadContextUsage;
84
+ }
85
+
86
+ export interface FullReadEvaluation {
87
+ decision: FullReadDecision;
88
+ reasonCode: FullReadReasonCode;
89
+ contextKnown: boolean;
90
+ availableTokens?: number;
91
+ allowedTokens?: number;
92
+ hardCeilingBytes: number;
93
+ }
94
+
95
+ export interface FullReadDetails {
96
+ schema: typeof FULL_READ_SCHEMA;
97
+ decision: FullReadDecision;
98
+ reasonCode: FullReadReasonCode;
99
+ pathHash: string;
100
+ byteSize: number;
101
+ lineCount: number;
102
+ estimatedTokens: number;
103
+ contextWindow?: number;
104
+ contextTokensBefore?: number;
105
+ availableTokens?: number;
106
+ allowedTokens?: number;
107
+ safetyMarginPercent: number;
108
+ maxAllowedContextFraction: number;
109
+ hardCeilingBytes: number;
110
+ maxBytesOverride?: number;
111
+ contextKnown: boolean;
112
+ encoding: FullReadEncoding;
113
+ bodyStored: false;
114
+ }
115
+
116
+ export interface FullReadRunInput {
117
+ cwd: string;
118
+ path: string;
119
+ encoding?: string;
120
+ maxBytesOverride?: number;
121
+ usage: FullReadContextUsage;
122
+ policy: FullReadPolicy;
123
+ io: FullReadIo;
124
+ estimateTokens: (text: string) => number;
125
+ }
126
+
127
+ export interface FullReadRunResult {
128
+ decision: FullReadDecision;
129
+ reasonCode: FullReadReasonCode;
130
+ content?: string;
131
+ details: FullReadDetails;
132
+ }
133
+
134
+ const BODY_LIKE_FIELDS = new Set([
135
+ "body",
136
+ "prompt",
137
+ "output",
138
+ "command",
139
+ "path",
140
+ "pattern",
141
+ "oldtext",
142
+ "newtext",
143
+ "stderr",
144
+ "diff",
145
+ "patch",
146
+ "content",
147
+ "text",
148
+ "secret",
149
+ "token",
150
+ "password",
151
+ "credential",
152
+ ]);
153
+
154
+ function errCode(error: unknown): string | undefined {
155
+ return typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string"
156
+ ? (error as { code: string }).code
157
+ : undefined;
158
+ }
159
+
160
+ function contextWindowKnown(usage: FullReadContextUsage): boolean {
161
+ return (
162
+ typeof usage.contextWindow === "number" &&
163
+ Number.isFinite(usage.contextWindow) &&
164
+ usage.contextWindow > 0 &&
165
+ typeof usage.tokens === "number" &&
166
+ Number.isFinite(usage.tokens) &&
167
+ usage.tokens >= 0
168
+ );
169
+ }
170
+
171
+ function resolveHardCeiling(policy: FullReadPolicy, maxBytesOverride?: number): number {
172
+ const ceiling = typeof maxBytesOverride === "number" && Number.isFinite(maxBytesOverride) && maxBytesOverride > 0
173
+ ? Math.min(Math.floor(maxBytesOverride), policy.hardCeilingBytesDefault)
174
+ : policy.hardCeilingBytesDefault;
175
+ return ceiling;
176
+ }
177
+
178
+ export function classifyPathSecret(targetPath: string, cwd: string, policy: FullReadPolicy): boolean {
179
+ return policy.zeroAccessPaths.some((pattern) => pathMatches(targetPath, pattern, cwd, cwd));
180
+ }
181
+
182
+ export function classifyPathForbiddenGenerated(targetPath: string, cwd: string, policy: FullReadPolicy): boolean {
183
+ return policy.forbiddenGeneratedPaths.some((pattern) => pathMatches(targetPath, pattern, cwd, cwd));
184
+ }
185
+
186
+ export function evaluateFullRead(facts: FullReadFacts, policy: FullReadPolicy, maxBytesOverride?: number): FullReadEvaluation {
187
+ const hardCeilingBytes = resolveHardCeiling(policy, maxBytesOverride);
188
+ const contextKnown = contextWindowKnown(facts.usage);
189
+
190
+ if (facts.isSecret) {
191
+ return { decision: "block", reasonCode: "path_secret_rejected", contextKnown, hardCeilingBytes };
192
+ }
193
+ if (facts.isForbiddenGenerated) {
194
+ return { decision: "block", reasonCode: "path_forbidden_generated", contextKnown, hardCeilingBytes };
195
+ }
196
+ if (facts.pathKind === "missing") {
197
+ return { decision: "block", reasonCode: "path_not_found", contextKnown, hardCeilingBytes };
198
+ }
199
+ if (facts.pathKind !== "file") {
200
+ return { decision: "block", reasonCode: "path_not_file", contextKnown, hardCeilingBytes };
201
+ }
202
+ if (facts.binaryRequested) {
203
+ return { decision: "block", reasonCode: "binary_not_supported", contextKnown, hardCeilingBytes };
204
+ }
205
+ if (!facts.readable) {
206
+ return { decision: "block", reasonCode: "path_not_readable", contextKnown, hardCeilingBytes };
207
+ }
208
+ if (facts.byteSize > hardCeilingBytes) {
209
+ return { decision: "block", reasonCode: "exceeds_hard_ceiling", contextKnown, hardCeilingBytes };
210
+ }
211
+
212
+ if (!contextKnown) {
213
+ return { decision: "observe", reasonCode: "context_unknown_fallback_pass", contextKnown: false, hardCeilingBytes };
214
+ }
215
+
216
+ const availableTokens = Math.max(0, Math.floor((facts.usage.contextWindow as number) - (facts.usage.tokens as number)));
217
+ const marginAllowed = Math.floor(availableTokens * (1 - policy.safetyMarginPercent / 100));
218
+ const fractionCap = Math.floor((facts.usage.contextWindow as number) * policy.maxAllowedContextFraction);
219
+ const allowedTokens = Math.max(0, Math.min(marginAllowed, fractionCap));
220
+
221
+ if (facts.estimatedTokens <= allowedTokens) {
222
+ return { decision: "pass", reasonCode: "full_read_pass", contextKnown: true, availableTokens, allowedTokens, hardCeilingBytes };
223
+ }
224
+ return { decision: "block", reasonCode: "exceeds_context_budget", contextKnown: true, availableTokens, allowedTokens, hardCeilingBytes };
225
+ }
226
+
227
+ function bodyLikeField(field: string): boolean {
228
+ const normalized = field.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
229
+ if (normalized === "bodystored" || normalized.endsWith("hash")) return false;
230
+ return BODY_LIKE_FIELDS.has(normalized) || (normalized.startsWith("raw") && BODY_LIKE_FIELDS.has(normalized.slice(3)));
231
+ }
232
+
233
+ export function fullReadBodyFreeViolations(value: unknown): string[] {
234
+ const violations: string[] = [];
235
+ const seen = new WeakSet<object>();
236
+
237
+ function visit(candidate: unknown, path: string): void {
238
+ if (typeof candidate !== "object" || candidate === null || seen.has(candidate)) return;
239
+ seen.add(candidate);
240
+ if (Array.isArray(candidate)) {
241
+ candidate.forEach((entry, index) => {
242
+ visit(entry, `${path}[${index}]`);
243
+ });
244
+ return;
245
+ }
246
+ for (const [field, entry] of Object.entries(candidate)) {
247
+ const fieldPath = `${path}.${field}`;
248
+ if (bodyLikeField(field)) violations.push(fieldPath);
249
+ visit(entry, fieldPath);
250
+ }
251
+ }
252
+
253
+ visit(value, "$");
254
+ return violations.sort();
255
+ }
256
+
257
+ function buildDetails(input: {
258
+ decision: FullReadDecision;
259
+ reasonCode: FullReadReasonCode;
260
+ pathHash: string;
261
+ byteSize: number;
262
+ lineCount: number;
263
+ estimatedTokens: number;
264
+ usage: FullReadContextUsage;
265
+ evaluation: FullReadEvaluation;
266
+ policy: FullReadPolicy;
267
+ encoding: FullReadEncoding;
268
+ maxBytesOverride?: number;
269
+ }): FullReadDetails {
270
+ const details: FullReadDetails = {
271
+ schema: FULL_READ_SCHEMA,
272
+ decision: input.decision,
273
+ reasonCode: input.reasonCode,
274
+ pathHash: input.pathHash,
275
+ byteSize: input.byteSize,
276
+ lineCount: input.lineCount,
277
+ estimatedTokens: input.estimatedTokens,
278
+ safetyMarginPercent: input.policy.safetyMarginPercent,
279
+ maxAllowedContextFraction: input.policy.maxAllowedContextFraction,
280
+ hardCeilingBytes: input.evaluation.hardCeilingBytes,
281
+ contextKnown: input.evaluation.contextKnown,
282
+ encoding: input.encoding,
283
+ bodyStored: false,
284
+ };
285
+ if (typeof input.usage.contextWindow === "number" && Number.isFinite(input.usage.contextWindow)) {
286
+ details.contextWindow = input.usage.contextWindow;
287
+ }
288
+ if (typeof input.usage.tokens === "number" && Number.isFinite(input.usage.tokens)) {
289
+ details.contextTokensBefore = input.usage.tokens;
290
+ }
291
+ if (typeof input.evaluation.availableTokens === "number") {
292
+ details.availableTokens = input.evaluation.availableTokens;
293
+ }
294
+ if (typeof input.evaluation.allowedTokens === "number") {
295
+ details.allowedTokens = input.evaluation.allowedTokens;
296
+ }
297
+ if (typeof input.maxBytesOverride === "number") {
298
+ details.maxBytesOverride = input.maxBytesOverride;
299
+ }
300
+ return details;
301
+ }
302
+
303
+ function inspectionFailedResult(input: FullReadRunInput, pathHash: string, byteSize: number): FullReadRunResult {
304
+ const evaluation: FullReadEvaluation = {
305
+ decision: "block",
306
+ reasonCode: "inspection_failed",
307
+ contextKnown: contextWindowKnown(input.usage),
308
+ hardCeilingBytes: resolveHardCeiling(input.policy, input.maxBytesOverride),
309
+ };
310
+ const details = buildDetails({
311
+ decision: "block",
312
+ reasonCode: "inspection_failed",
313
+ pathHash,
314
+ byteSize,
315
+ lineCount: 0,
316
+ estimatedTokens: 0,
317
+ usage: input.usage,
318
+ evaluation,
319
+ policy: input.policy,
320
+ encoding: "utf8",
321
+ maxBytesOverride: input.maxBytesOverride,
322
+ });
323
+ return { decision: "block", reasonCode: "inspection_failed", details };
324
+ }
325
+
326
+ export function runFullRead(input: FullReadRunInput): FullReadRunResult {
327
+ const targetPath = resolve(input.cwd, input.path);
328
+ const pathHash = sha256Hex(targetPath);
329
+ const binaryRequested = !(input.encoding === "utf8" || input.encoding === undefined);
330
+ const encoding: FullReadEncoding = "utf8";
331
+ const isSecret = classifyPathSecret(targetPath, input.cwd, input.policy);
332
+ let isForbiddenGenerated = classifyPathForbiddenGenerated(targetPath, input.cwd, input.policy);
333
+
334
+ let pathKind: FullReadFacts["pathKind"] = "missing";
335
+ let readable = false;
336
+ let byteSize = 0;
337
+ let inspectionFailed = false;
338
+
339
+ try {
340
+ const stat = input.io.stat(targetPath);
341
+ pathKind = stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "other";
342
+ byteSize = stat.size;
343
+ try {
344
+ input.io.accessReadable(targetPath);
345
+ readable = true;
346
+ } catch {
347
+ readable = false;
348
+ }
349
+ } catch (error) {
350
+ if (errCode(error) === "ENOENT") {
351
+ pathKind = "missing";
352
+ byteSize = 0;
353
+ readable = false;
354
+ } else {
355
+ inspectionFailed = true;
356
+ }
357
+ }
358
+
359
+ if (inspectionFailed) {
360
+ return inspectionFailedResult(input, pathHash, 0);
361
+ }
362
+
363
+ // Canonical-path zero-access check: realpath the target (this follows symlinks,
364
+ // unlike resolve) and re-run classifyPathSecret on the canonical path. This
365
+ // closes the symlink bypass where an in-cwd symlink to ~/.ssh/id_rsa or a .env
366
+ // would otherwise evade the lexical targetPath check. Runs for every existing
367
+ // path kind and BEFORE any readFile, mirroring file-tool-preflight's
368
+ // zeroAccessRealpathFailure.
369
+ if (pathKind !== "missing") {
370
+ let canonicalPath: string;
371
+ try {
372
+ canonicalPath = input.io.realpath(targetPath);
373
+ } catch {
374
+ return inspectionFailedResult(input, pathHash, byteSize);
375
+ }
376
+ const canonicalSecret = classifyPathSecret(canonicalPath, input.cwd, input.policy);
377
+ if (canonicalSecret && !isSecret) {
378
+ const canonicalEvaluation: FullReadEvaluation = {
379
+ decision: "block",
380
+ reasonCode: "symlink_resolves_to_zero_access",
381
+ contextKnown: contextWindowKnown(input.usage),
382
+ hardCeilingBytes: resolveHardCeiling(input.policy, input.maxBytesOverride),
383
+ };
384
+ const canonicalDetails = buildDetails({
385
+ decision: "block",
386
+ reasonCode: "symlink_resolves_to_zero_access",
387
+ pathHash,
388
+ byteSize,
389
+ lineCount: 0,
390
+ estimatedTokens: 0,
391
+ usage: input.usage,
392
+ evaluation: canonicalEvaluation,
393
+ policy: input.policy,
394
+ encoding,
395
+ maxBytesOverride: input.maxBytesOverride,
396
+ });
397
+ return { decision: "block", reasonCode: "symlink_resolves_to_zero_access", details: canonicalDetails };
398
+ }
399
+ isForbiddenGenerated = isForbiddenGenerated || classifyPathForbiddenGenerated(canonicalPath, input.cwd, input.policy);
400
+ }
401
+
402
+ // Pre-evaluate WITHOUT reading content. estimatedTokens is 0 here, which only
403
+ // matters for the budget branches — so any "block" from this pass is one of
404
+ // the safety/path-kind/binary/readable/hard-ceiling branches and must return
405
+ // immediately without touching file content.
406
+ const preliminaryFacts: FullReadFacts = {
407
+ pathKind,
408
+ isSecret,
409
+ isForbiddenGenerated,
410
+ binaryRequested,
411
+ readable,
412
+ byteSize,
413
+ estimatedTokens: 0,
414
+ usage: input.usage,
415
+ };
416
+ const preliminaryEvaluation = evaluateFullRead(preliminaryFacts, input.policy, input.maxBytesOverride);
417
+ if (preliminaryEvaluation.decision === "block") {
418
+ const details = buildDetails({
419
+ decision: preliminaryEvaluation.decision,
420
+ reasonCode: preliminaryEvaluation.reasonCode,
421
+ pathHash,
422
+ byteSize,
423
+ lineCount: 0,
424
+ estimatedTokens: 0,
425
+ usage: input.usage,
426
+ evaluation: preliminaryEvaluation,
427
+ policy: input.policy,
428
+ encoding,
429
+ maxBytesOverride: input.maxBytesOverride,
430
+ });
431
+ return { decision: preliminaryEvaluation.decision, reasonCode: preliminaryEvaluation.reasonCode, details };
432
+ }
433
+
434
+ let content: string;
435
+ try {
436
+ content = input.io.readFile(targetPath);
437
+ } catch {
438
+ return inspectionFailedResult(input, pathHash, byteSize);
439
+ }
440
+
441
+ const estimatedTokens = input.estimateTokens(content);
442
+ const lineCount = content === "" ? 0 : content.split("\n").length;
443
+
444
+ const facts: FullReadFacts = {
445
+ pathKind,
446
+ isSecret,
447
+ isForbiddenGenerated,
448
+ binaryRequested,
449
+ readable,
450
+ byteSize,
451
+ estimatedTokens,
452
+ usage: input.usage,
453
+ };
454
+ const evaluation = evaluateFullRead(facts, input.policy, input.maxBytesOverride);
455
+
456
+ const details = buildDetails({
457
+ decision: evaluation.decision,
458
+ reasonCode: evaluation.reasonCode,
459
+ pathHash,
460
+ byteSize,
461
+ lineCount,
462
+ estimatedTokens,
463
+ usage: input.usage,
464
+ evaluation,
465
+ policy: input.policy,
466
+ encoding,
467
+ maxBytesOverride: input.maxBytesOverride,
468
+ });
469
+
470
+ const result: FullReadRunResult = { decision: evaluation.decision, reasonCode: evaluation.reasonCode, details };
471
+ if (evaluation.decision === "pass" || evaluation.decision === "observe") {
472
+ result.content = content;
473
+ }
474
+ return result;
475
+ }
476
+
477
+ /**
478
+ * Default IO backed by `node:fs` synchronous helpers. The runtime tool (later
479
+ * slice) injects this unless tests pass an in-memory `FullReadIo`.
480
+ */
481
+ export const FULL_READ_DEFAULT_IO: FullReadIo = {
482
+ stat: (path: string): FullReadStat => statSync(path) as FullReadStat,
483
+ accessReadable: (path: string): void => {
484
+ accessSync(path, fsConstants.R_OK);
485
+ },
486
+ readFile: (path: string): string => readFileSync(path, "utf8"),
487
+ realpath: (path: string): string => realpathSync(path),
488
+ };