acstack 0.52.6 → 0.53.1

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/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acstack-cli",
3
- "version": "0.52.6",
3
+ "version": "0.53.1",
4
4
  "description": "CLI for the ACStack agentic coding stack",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -305,7 +305,7 @@ Three refusals protect a file neither command can handle.
305
305
 
306
306
  ## 3. workflow: the switches
307
307
 
308
- *Five booleans sit in the block. Each one changes how much discipline ACStack applies. A sixth key decides how the agent asks you a question.*
308
+ *Six booleans sit in the block. Each one changes how much discipline ACStack applies. A byte count tunes the large-read guard, and one more key decides how the agent asks you a question.*
309
309
 
310
310
  | Key | Values | Default | What changes |
311
311
  | --- | --- | --- | --- |
@@ -314,6 +314,8 @@ Three refusals protect a file neither command can handle.
314
314
  | `workflow.tdd.orchestrator_strict` | `true`, `false` | none | deprecated compatibility boolean; no delegation effect, including explicit `true` |
315
315
  | `workflow.frontend_verification` | `true`, `false` | `false` | the frontend-verifier runs only when this is `true`, the file configures a `frontend` block, and the change touched frontend files |
316
316
  | `workflow.design_review` | `true`, `false` | `false` | a design review runs on a UI change only when this is `true`; it needs the impeccable plugin installed and switched on, which `acstack config check` reports on |
317
+ | `workflow.read_guard` | `true`, `false` | `true` | `false` turns off the large-read guard; section 3.3 describes it |
318
+ | `workflow.read_guard_bytes` | a whole number, 1 or more | `32768` | the smallest file, in bytes, the large-read guard stops |
317
319
 
318
320
  How a boolean reads:
319
321
 
@@ -361,6 +363,26 @@ How a boolean reads:
361
363
  - `tool` falls back to inline prose for any round it cannot hold
362
364
  - Two things exceed it: a round of more than four questions, and any question with no discrete options
363
365
 
366
+ ### 3.3. The large-read guard
367
+
368
+ *The guard stops a main agent's first whole read of a large file once, and points it to a cheaper way to read.*
369
+
370
+ - The guard runs on Claude Code and Codex as a `PreToolUse` hook (`hooks/read-guard.mjs`), and on pi in the extension's `tool_call` handler
371
+ - Both share one policy, `hooks/lib/read-guard.mjs`
372
+ - It stops a whole-file read of a file of at least `workflow.read_guard_bytes` bytes
373
+ - A whole-file read is the read tool with no offset or limit, or a single-file `cat`, `less`, `more`, `nl`, or `bat` with no pipe, redirect, or variable
374
+ - The block message suggests a ranged read, a search, or a read-only subagent
375
+ - Repeating the same read passes, and so does any later whole read of the same unchanged file
376
+ - The guard is a nudge, not a security boundary
377
+ - A ranged read, a pipe, or `head` always passes
378
+ - The guard watches only a main agent, meaning a session the user started
379
+ - Claude Code and Codex subagents carry `agent_id` in the hook input, so they pass
380
+ - ac-teams members (`ACSTACK_AGENT_ROLE` set to anything but `main`, or `AC_TEAMS_MEMBER` set) and pi-subagents children (`PI_SUBAGENT_CHILD`) pass
381
+ - On pi, a session with a `parentSession` header or no session file passes, so the guard skips a forked session too
382
+ - When the guard cannot tell, it lets the read through
383
+ - Any error inside the guard lets the read through
384
+ - Claude Code and Codex record passed reads in `${TMPDIR:-/tmp}/acstack-read-guard-<uid>/<session_id>.json`, a folder only its owner can open; pi keeps them in memory
385
+
364
386
  ## 4. workflow.maps
365
387
 
366
388
  *Two keys record where a map's nodes live, and what happens to those nodes at the end.*
@@ -634,6 +656,8 @@ workflow:
634
656
  # An absent key reads as false. Section 3 says why.
635
657
  frontend_verification: true
636
658
  design_review: false
659
+ read_guard: true
660
+ read_guard_bytes: 32768
637
661
  maps:
638
662
  # tracker has no default: the map skill picks and writes it
639
663
  persist: false
package/hooks/hooks.json CHANGED
@@ -54,6 +54,17 @@
54
54
  ]
55
55
  }
56
56
  ],
57
+ "PreToolUse": [
58
+ {
59
+ "matcher": "Read|Bash",
60
+ "hooks": [
61
+ {
62
+ "type": "command",
63
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/read-guard.mjs\""
64
+ }
65
+ ]
66
+ }
67
+ ],
57
68
  "UserPromptSubmit": [
58
69
  {
59
70
  "hooks": [
@@ -0,0 +1,190 @@
1
+ // The large-read guard's policy, shared by hooks/read-guard.mjs (Claude Code
2
+ // and Codex) and pi/extension.ts. It knows nothing about any harness: callers
3
+ // say whether the agent is a main agent and pass a retry ledger.
4
+ //
5
+ // A main agent's first whole-file read of a large file is blocked with a
6
+ // redirect message; an identical repeat passes. Every error allows the read,
7
+ // because the guard saves context and must never trap an agent.
8
+ //
9
+ // Design: .acstack/designs/large-read-guard/overview.md
10
+
11
+ import * as fs from 'node:fs';
12
+ import * as os from 'node:os';
13
+ import * as path from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+
16
+ export const DEFAULT_MIN_BYTES = 32768;
17
+
18
+ const SHELL_READERS = new Set(['cat', 'less', 'more', 'nl', 'bat']);
19
+
20
+ /** True when a launcher marked this process as anything but a main agent. */
21
+ export function envSaysNotMain(env = process.env) {
22
+ const role = env.ACSTACK_AGENT_ROLE;
23
+ return Boolean(role && role !== 'main') || Boolean(env.AC_TEAMS_MEMBER);
24
+ }
25
+
26
+ /**
27
+ * The one settings function: workflow.read_guard and
28
+ * workflow.read_guard_bytes from the nearest
29
+ * .acstack/project.yaml above cwd. Hand-rolled like the other hooks' readers,
30
+ * because no YAML library is guaranteed at hook time.
31
+ */
32
+ export function readGuardSettings(cwd) {
33
+ const settings = { enabled: true, minBytes: DEFAULT_MIN_BYTES };
34
+ const yaml = projectYaml(cwd);
35
+ if (yaml === undefined) return settings;
36
+ const stack = [];
37
+ for (const raw of yaml.split('\n')) {
38
+ if (!raw.trim() || raw.trimStart().startsWith('#')) continue;
39
+ const stripped = raw.replace(/^ +/, '');
40
+ const indent = raw.length - stripped.length;
41
+ if (stripped.startsWith('- ') || !stripped.includes(':')) continue;
42
+ const sep = stripped.indexOf(':');
43
+ const key = stripped.slice(0, sep).trim().replace(/^["']+|["']+$/g, '');
44
+ const value = stripped.slice(sep + 1).split('#')[0].trim().replace(/^["']+|["']+$/g, '');
45
+ while (stack.length && stack[stack.length - 1][0] >= indent) stack.pop();
46
+ const keyPath = [...stack.map(([, k]) => k), key].join('.');
47
+ if (!value) {
48
+ stack.push([indent, key]);
49
+ continue;
50
+ }
51
+ if (keyPath === 'workflow.read_guard') settings.enabled = !['false', 'no', 'off'].includes(value.toLowerCase());
52
+ if (keyPath === 'workflow.read_guard_bytes' && /^\d+$/.test(value)) settings.minBytes = Number(value);
53
+ }
54
+ return settings;
55
+ }
56
+
57
+ function projectYaml(cwd) {
58
+ let dir = path.resolve(cwd);
59
+ while (true) {
60
+ const file = path.join(dir, '.acstack', 'project.yaml');
61
+ if (fs.existsSync(file)) {
62
+ try {
63
+ return fs.readFileSync(file, 'utf8');
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ const parent = path.dirname(dir);
69
+ if (parent === dir) return undefined;
70
+ dir = parent;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The file a tool call reads whole, or undefined when the call is not a
76
+ * whole-file read: a ranged read, a pipe, a script, or anything unrecognised.
77
+ */
78
+ export function wholeReadTarget(toolName, input, cwd) {
79
+ if (!input || typeof input !== 'object') return undefined;
80
+ const name = String(toolName).toLowerCase();
81
+ if (name === 'read') {
82
+ if (input.offset != null || input.limit != null) return undefined;
83
+ const file = input.file_path ?? input.path;
84
+ return typeof file === 'string' && file ? resolvePath(file, cwd) : undefined;
85
+ }
86
+ if (name === 'bash') {
87
+ const file = shellReadTarget(input.command);
88
+ return file === undefined ? undefined : resolvePath(file, cwd);
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** The single file of `cat|less|more|nl|bat [-flags] <file>`, nothing more. */
94
+ export function shellReadTarget(command) {
95
+ if (typeof command !== 'string') return undefined;
96
+ // Pipes, redirects, chains, substitutions, globs, and variables all pass.
97
+ if (/[|;&<>`$()*?\n\\]/.test(command)) return undefined;
98
+ const words = [];
99
+ for (const match of command.trim().matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)) {
100
+ words.push(match[1] ?? match[2] ?? match[3]);
101
+ }
102
+ const [reader, ...args] = words;
103
+ if (!SHELL_READERS.has(reader)) return undefined;
104
+ const flags = args.filter((arg) => arg.startsWith('-'));
105
+ const files = args.filter((arg) => !arg.startsWith('-'));
106
+ // bat -r / --line-range reads a range.
107
+ if (reader === 'bat' && flags.some((flag) => flag === '-r' || flag.startsWith('--line-range'))) return undefined;
108
+ return files.length === 1 ? files[0] : undefined;
109
+ }
110
+
111
+ function resolvePath(file, cwd) {
112
+ if (file === '~' || file.startsWith('~/')) return path.join(os.homedir(), file.slice(1));
113
+ return path.resolve(cwd, file);
114
+ }
115
+
116
+ /**
117
+ * Decide one tool call. Returns { block: false } or { block: true, message }.
118
+ * `ledger` has has(key) and add(key); add throws when it cannot record, and
119
+ * then the read is allowed so a repeat can never be trapped.
120
+ */
121
+ export function decide({ toolName, toolInput, cwd, main, ledger, settings = readGuardSettings(cwd) }) {
122
+ try {
123
+ if (!main || !settings.enabled || !ledger) return { block: false };
124
+ const file = wholeReadTarget(toolName, toolInput, cwd);
125
+ if (!file) return { block: false };
126
+ const stat = fs.statSync(file);
127
+ if (!stat.isFile() || stat.size < settings.minBytes) return { block: false };
128
+ const key = `${fs.realpathSync(file)}\0${stat.size}\0${stat.mtimeMs}`;
129
+ if (ledger.has(key)) return { block: false };
130
+ ledger.add(key);
131
+ return { block: true, message: blockMessage(stat.size) };
132
+ } catch {
133
+ return { block: false };
134
+ }
135
+ }
136
+
137
+ export function blockMessage(bytes) {
138
+ const kb = Math.round(bytes / 1024);
139
+ const tokens = Math.round(bytes / 4 / 1000) * 1000;
140
+ return [
141
+ `This file is ${kb} KB (about ${tokens.toLocaleString('en-US')} tokens), and reading all of it is usually unnecessary. Instead:`,
142
+ '',
143
+ '- Search for what you need, then read only that range',
144
+ "- Or hand the question to an investigator, as ACStack's Delegate investigation section describes",
145
+ '',
146
+ 'If you really need the whole file, repeat this exact read and it will go through.',
147
+ ].join('\n');
148
+ }
149
+
150
+ /**
151
+ * A per-session ledger file in a folder only this user can open:
152
+ * ${TMPDIR:-/tmp}/acstack-read-guard-<uid>/<session_id>.json.
153
+ * Returns undefined without a session id, so the guard stays off.
154
+ */
155
+ export function fileLedger(sessionId, tmpdir = os.tmpdir()) {
156
+ if (typeof sessionId !== 'string' || !sessionId) return undefined;
157
+ const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
158
+ const dir = path.join(tmpdir, `acstack-read-guard-${uid ?? os.userInfo().username}`);
159
+ const name = /^[A-Za-z0-9._-]{1,128}$/.test(sessionId) && !sessionId.startsWith('.')
160
+ ? sessionId
161
+ : createHash('sha256').update(sessionId).digest('hex');
162
+ const file = path.join(dir, `${name}.json`);
163
+
164
+ const ownedDir = () => {
165
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
166
+ const stat = fs.lstatSync(dir);
167
+ // Another user's folder could hold planted entries that switch the guard off.
168
+ if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
169
+ throw new Error(`refusing ledger folder ${dir}`);
170
+ }
171
+ };
172
+ const load = () => {
173
+ ownedDir();
174
+ try {
175
+ const keys = JSON.parse(fs.readFileSync(file, 'utf8'));
176
+ return new Set(Array.isArray(keys) ? keys : []);
177
+ } catch {
178
+ return new Set();
179
+ }
180
+ };
181
+ return {
182
+ has: (key) => load().has(key),
183
+ add: (key) => {
184
+ const keys = load().add(key);
185
+ const tmp = `${file}.${process.pid}.tmp`;
186
+ fs.writeFileSync(tmp, JSON.stringify([...keys]), { mode: 0o600 });
187
+ fs.renameSync(tmp, file);
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,31 @@
1
+ // PreToolUse hook for Claude Code and Codex: the large-read guard.
2
+ // Reads the hook input on stdin and prints a deny decision, or nothing.
3
+ // Any failure allows the call, so the guard can never trap an agent.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { decide, envSaysNotMain, fileLedger } from './lib/read-guard.mjs';
7
+
8
+ try {
9
+ const input = JSON.parse(readFileSync(0, 'utf8'));
10
+ // Subagents and in-process teammates carry agent_id in both harnesses.
11
+ const main = !input.agent_id && !envSaysNotMain();
12
+ const cwd = input.cwd || process.cwd();
13
+ const result = decide({
14
+ toolName: input.tool_name,
15
+ toolInput: input.tool_input,
16
+ cwd,
17
+ main,
18
+ ledger: main ? fileLedger(input.session_id) : undefined,
19
+ });
20
+ if (result.block) {
21
+ console.log(JSON.stringify({
22
+ hookSpecificOutput: {
23
+ hookEventName: 'PreToolUse',
24
+ permissionDecision: 'deny',
25
+ permissionDecisionReason: result.message,
26
+ },
27
+ }));
28
+ }
29
+ } catch {
30
+ // Allow.
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acstack",
3
- "version": "0.52.6",
3
+ "version": "0.53.1",
4
4
  "private": false,
5
5
  "description": "Agentic coding stack with first-class Claude Code and pi support: skills, orchestrator instructions, and verification agents.",
6
6
  "license": "MIT",
package/pi/extension.ts CHANGED
@@ -17,6 +17,8 @@
17
17
  * session_compact re-inject .acstack/HANDOFF.md after a compaction, and
18
18
  * restart the nudge ladder (hooks/session-start-handoff.sh,
19
19
  * hooks/context-pressure.sh)
20
+ * tool_call block a main agent's first whole read of a large
21
+ * file (hooks/read-guard.mjs)
20
22
  *
21
23
  * It also registers /acstack <skill>, a /acstack:<skill> command per skill,
22
24
  * and /acstack-writing-style for the bundled/default/custom style choice.
@@ -33,6 +35,8 @@ import * as path from "node:path";
33
35
  import { fileURLToPath } from "node:url";
34
36
  import { execFileSync } from "node:child_process";
35
37
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
38
+ // @ts-ignore -- plain ESM shared with hooks/read-guard.mjs, no type declarations
39
+ import { decide, envSaysNotMain } from "../hooks/lib/read-guard.mjs";
36
40
 
37
41
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
38
42
  const troubleshoot = path.join(packageRoot, "TROUBLESHOOTING.md");
@@ -777,6 +781,24 @@ export default function acstack(pi: ExtensionAPI): void {
777
781
  }
778
782
  });
779
783
 
784
+ // The large-read guard. Environment markers are read once, at load, which
785
+ // is when pi-subagents sets them for an in-process child. A session with a
786
+ // parentSession header (a tintinweb/pi-subagents child, or a user's /fork)
787
+ // or with no session file is not treated as main: unclear means off.
788
+ const readGuardOff = delegated || envSaysNotMain();
789
+ const readLedgers = new Map<string, Set<string>>();
790
+ pi.on("tool_call", async (event, ctx) => {
791
+ if (readGuardOff || (event.toolName !== "read" && event.toolName !== "bash")) return;
792
+ const sessions = ctx.sessionManager;
793
+ const sessionId = sessions.getSessionId();
794
+ const main = Boolean(sessionId && sessions.getSessionFile?.() && !sessions.getHeader?.()?.parentSession);
795
+ if (!main) return;
796
+ let ledger = readLedgers.get(sessionId);
797
+ if (!ledger) readLedgers.set(sessionId, (ledger = new Set()));
798
+ const result = decide({ toolName: event.toolName, toolInput: event.input, cwd: ctx.cwd, main, ledger });
799
+ if (result.block) return { block: true, reason: result.message };
800
+ });
801
+
780
802
  const runSkill = (name: string, args: string, ctx: ExtensionContext): void => {
781
803
  const file = skills.get(name)?.file;
782
804
  const message = file && skillInvocation(name, file, args);