@pugi/cli 0.1.0-beta.53 → 0.1.0-beta.55
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/dist/core/audit/audit-trail.js +81 -0
- package/dist/core/context/markdown-loader.js +22 -4
- package/dist/core/format/osc8-link.js +28 -0
- package/dist/core/security/injection-scanner.js +367 -0
- package/dist/core/settings.js +168 -5
- package/dist/runtime/cli.js +54 -5
- package/dist/runtime/version.js +1 -1
- package/dist/tools/bash.js +141 -2
- package/dist/tools/file-tools.js +46 -0
- package/package.json +2 -2
|
@@ -43,6 +43,7 @@ import { appendFileSync, mkdirSync } from 'node:fs';
|
|
|
43
43
|
import { createHash } from 'node:crypto';
|
|
44
44
|
import { homedir } from 'node:os';
|
|
45
45
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
46
|
+
import { collectStrings, scanForInjection, summarizeFindings, } from '../security/injection-scanner.js';
|
|
46
47
|
/**
|
|
47
48
|
* Opt-out env var. Mirrors the convention every other Pugi feature uses
|
|
48
49
|
* (`PUGI_BARE`, `PUGI_AGENTMEMORY_RECALL_ENABLED=false`, etc.).
|
|
@@ -183,6 +184,32 @@ export function writeAuditEvent(input) {
|
|
|
183
184
|
encoding: 'utf8',
|
|
184
185
|
mode: 0o600,
|
|
185
186
|
});
|
|
187
|
+
// Injection scan (ported KeiSeiKit `injection_patterns.rs`,
|
|
188
|
+
// Apache-2.0). Wrap the OUTBOUND `data` payload through the
|
|
189
|
+
// scanner. Findings emit a SECOND audit line of type
|
|
190
|
+
// `injection_detected` so an operator (or SOC pipeline) sees a
|
|
191
|
+
// structured, append-only record without losing the original
|
|
192
|
+
// event. Never blocks the write — hard-block requires a separate
|
|
193
|
+
// CEO-signed PR.
|
|
194
|
+
//
|
|
195
|
+
// Recursion guard: the `injection_detected` event itself carries
|
|
196
|
+
// matched substrings (intentional — they are the evidence). We
|
|
197
|
+
// skip scanning it to avoid an infinite loop of self-detections.
|
|
198
|
+
if (input.event !== 'injection_detected') {
|
|
199
|
+
const findings = scanAuditPayload(input.data);
|
|
200
|
+
if (findings.length > 0) {
|
|
201
|
+
emitInjectionDetected({
|
|
202
|
+
findings,
|
|
203
|
+
triggeringEvent: input.event,
|
|
204
|
+
sessionId: input.sessionId,
|
|
205
|
+
workspaceRoot: input.workspaceRoot,
|
|
206
|
+
tenant: input.tenant,
|
|
207
|
+
env: input.env,
|
|
208
|
+
home: input.home,
|
|
209
|
+
now: input.now,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
186
213
|
}
|
|
187
214
|
catch {
|
|
188
215
|
// Audit failures must NEVER break a dispatch. The session log + the
|
|
@@ -191,4 +218,58 @@ export function writeAuditEvent(input) {
|
|
|
191
218
|
// via the doctor probe; for now silent no-op is the contract.
|
|
192
219
|
}
|
|
193
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* Fold the audit `data` payload into a single string and scan it for
|
|
223
|
+
* prompt-injection / invisible-unicode / secret markers. Returns the
|
|
224
|
+
* empty array on clean payloads.
|
|
225
|
+
*
|
|
226
|
+
* Exported for the spec — the scanner module owns the algorithm, this
|
|
227
|
+
* helper owns the payload-walking glue.
|
|
228
|
+
*/
|
|
229
|
+
export function scanAuditPayload(data) {
|
|
230
|
+
// Fold every string anywhere in the payload (keys included) into a
|
|
231
|
+
// single buffer separated by NULs. NUL keeps regex anchors honest
|
|
232
|
+
// (no accidental cross-field match for a `^system:` pattern) without
|
|
233
|
+
// adding bytes that themselves could become a pattern.
|
|
234
|
+
const fragments = collectStrings(data);
|
|
235
|
+
if (fragments.length === 0)
|
|
236
|
+
return [];
|
|
237
|
+
const joined = fragments.join('\0');
|
|
238
|
+
return scanForInjection(joined);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Build the `injection_detected` envelope payload and recurse into
|
|
242
|
+
* `writeAuditEvent` to append it. The recursion is bounded — the
|
|
243
|
+
* recursion guard in `writeAuditEvent` short-circuits on the
|
|
244
|
+
* `injection_detected` event so we never re-scan ourselves.
|
|
245
|
+
*/
|
|
246
|
+
function emitInjectionDetected(input) {
|
|
247
|
+
const summary = summarizeFindings(input.findings);
|
|
248
|
+
// Cap the findings array in the audit line so a payload with
|
|
249
|
+
// hundreds of invisible-unicode hits does not bloat the JSONL row.
|
|
250
|
+
// The summary still carries `total` so operators see the real count.
|
|
251
|
+
const MAX_FINDINGS_PER_EVENT = 32;
|
|
252
|
+
const truncated = input.findings.length > MAX_FINDINGS_PER_EVENT;
|
|
253
|
+
const capped = truncated
|
|
254
|
+
? input.findings.slice(0, MAX_FINDINGS_PER_EVENT)
|
|
255
|
+
: [...input.findings];
|
|
256
|
+
writeAuditEvent({
|
|
257
|
+
event: 'injection_detected',
|
|
258
|
+
sessionId: input.sessionId,
|
|
259
|
+
workspaceRoot: input.workspaceRoot,
|
|
260
|
+
tenant: input.tenant,
|
|
261
|
+
env: input.env,
|
|
262
|
+
home: input.home,
|
|
263
|
+
now: input.now,
|
|
264
|
+
data: {
|
|
265
|
+
triggeringEvent: input.triggeringEvent,
|
|
266
|
+
summary,
|
|
267
|
+
findings: capped,
|
|
268
|
+
truncated,
|
|
269
|
+
// KeiSeiKit attribution is recorded inline so a SOC pipeline
|
|
270
|
+
// grepping for the upstream project name lands here.
|
|
271
|
+
detector: 'keiseikit-injection-patterns',
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
194
275
|
//# sourceMappingURL=audit-trail.js.map
|
|
@@ -26,22 +26,40 @@ export const MAX_TOTAL_BYTES = 64 * 1024;
|
|
|
26
26
|
/**
|
|
27
27
|
* Source filenames we look for at the workspace root. Order matters:
|
|
28
28
|
* PUGI.md is the canonical Pugi-native file; AGENTS.md is the
|
|
29
|
-
* cross-CLI compatibility shim used by other agentic CLIs.
|
|
29
|
+
* cross-CLI compatibility shim used by other agentic CLIs; CLAUDE.md
|
|
30
|
+
* is the Claude Code drop-in compat shim (Wave 7 #20, 2026-05-29) —
|
|
31
|
+
* operators migrating from CC routinely keep a workspace-root
|
|
32
|
+
* `CLAUDE.md` documenting project conventions, and we pick it up so
|
|
33
|
+
* Pugi sees the same ambient guidance without a manual rename.
|
|
34
|
+
*
|
|
35
|
+
* All three files are loaded when present (no "first one wins"
|
|
36
|
+
* dropdown). Each entry is HTML-stripped + @import-expanded + capped
|
|
37
|
+
* against the shared 64 KB budget. Pugi's precedence convention is
|
|
38
|
+
* "shown last = highest specificity"; PUGI.md / AGENTS.md / CLAUDE.md
|
|
39
|
+
* each have a stable position in this list so the context builder's
|
|
40
|
+
* render order is deterministic.
|
|
30
41
|
*/
|
|
31
|
-
export const MARKDOWN_SOURCES = ['PUGI.md', 'AGENTS.md'];
|
|
42
|
+
export const MARKDOWN_SOURCES = ['PUGI.md', 'AGENTS.md', 'CLAUDE.md'];
|
|
32
43
|
/**
|
|
33
44
|
* Load PUGI.md + AGENTS.md from `workspaceRoot`. Either or both may be
|
|
34
45
|
* absent. Returns the combined load result with per-file detail plus a
|
|
35
46
|
* flat list of warnings (best-effort: a missing file is a warning, not
|
|
36
47
|
* an error).
|
|
37
48
|
*/
|
|
38
|
-
export async function loadMarkdownContext(workspaceRoot) {
|
|
49
|
+
export async function loadMarkdownContext(workspaceRoot, env = process.env) {
|
|
39
50
|
const warnings = [];
|
|
40
51
|
const loaded = [];
|
|
41
52
|
let budgetRemaining = MAX_TOTAL_BYTES;
|
|
42
53
|
const visited = new Set();
|
|
43
54
|
const absRoot = resolve(workspaceRoot);
|
|
44
|
-
|
|
55
|
+
// Wave 7 #20 (2026-05-29): allow operators to opt OUT of CLAUDE.md
|
|
56
|
+
// ingest via `PUGI_CC_COMPAT_DISABLE=1`. PUGI.md / AGENTS.md remain
|
|
57
|
+
// loaded — they are Pugi-native surfaces, not CC compat shims.
|
|
58
|
+
const ccCompatDisabled = env.PUGI_CC_COMPAT_DISABLE === '1';
|
|
59
|
+
const activeSources = ccCompatDisabled
|
|
60
|
+
? MARKDOWN_SOURCES.filter((s) => s !== 'CLAUDE.md')
|
|
61
|
+
: MARKDOWN_SOURCES;
|
|
62
|
+
for (const source of activeSources) {
|
|
45
63
|
const candidate = resolve(absRoot, source);
|
|
46
64
|
if (!existsSync(candidate)) {
|
|
47
65
|
warnings.push({
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSC 8 hyperlink helpers — turns workspace-relative file paths into
|
|
3
|
+
* clickable links in modern terminals (iTerm2, kitty, Windows Terminal,
|
|
4
|
+
* VS Code integrated terminal, Alacritty, WezTerm). Dumb terminals and
|
|
5
|
+
* pipes ignore the escape sequence and render only the visible label.
|
|
6
|
+
*
|
|
7
|
+
* CEO P2 #38 — Wave 7 artifact linking. Triggers ONLY when:
|
|
8
|
+
* - stdout is a TTY
|
|
9
|
+
* - PUGI_ARTIFACT_LINKS_DISABLE !== '1'
|
|
10
|
+
* - NO_COLOR is not set
|
|
11
|
+
*/
|
|
12
|
+
import { resolve, isAbsolute } from 'node:path';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
const ESC = '';
|
|
15
|
+
export function linkArtifact(pathRel, opts) {
|
|
16
|
+
const env = opts.env ?? process.env;
|
|
17
|
+
const tty = opts.isTty ?? process.stdout.isTTY ?? false;
|
|
18
|
+
if (!tty)
|
|
19
|
+
return pathRel;
|
|
20
|
+
if (env['PUGI_ARTIFACT_LINKS_DISABLE'] === '1')
|
|
21
|
+
return pathRel;
|
|
22
|
+
if (env['NO_COLOR'] && env['NO_COLOR'].length > 0)
|
|
23
|
+
return pathRel;
|
|
24
|
+
const abs = isAbsolute(pathRel) ? pathRel : resolve(opts.workspaceRoot, pathRel);
|
|
25
|
+
const url = pathToFileURL(abs).href;
|
|
26
|
+
return `${ESC}]8;;${url}${ESC}\\${pathRel}${ESC}]8;;${ESC}\\`;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=osc8-link.js.map
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-injection scanner — TypeScript port of KeiSeiKit's
|
|
3
|
+
* `injection_patterns.rs` (Apache-2.0, KeiSeiLab).
|
|
4
|
+
*
|
|
5
|
+
* Upstream source:
|
|
6
|
+
* `_primitives/_rust/kei-memory/src/injection_patterns.rs`
|
|
7
|
+
* from https://github.com/Pugi-dev/KeiSeiKit (private mirror).
|
|
8
|
+
*
|
|
9
|
+
* Scope of the port:
|
|
10
|
+
* - Pattern TABLES are ported verbatim (regex + invisible-codepoint
|
|
11
|
+
* set + ChatML tags + role-prefix patterns). The substring/secret
|
|
12
|
+
* rows (curl-with-bearer, aws_secret keyword, api_key URL, openssh
|
|
13
|
+
* PEM markers, long-base64 blob heuristic) are KEPT in this port —
|
|
14
|
+
* they harden writes through memory/audit paths against accidental
|
|
15
|
+
* credential pasting.
|
|
16
|
+
* - Detection logic is rewritten in TypeScript. The Rust upstream
|
|
17
|
+
* uses `regex::Regex` + a separate `injection_guard.rs` that owns
|
|
18
|
+
* the "should I block?" decision. Pugi's port collapses both
|
|
19
|
+
* responsibilities into a single function (`scanForInjection`)
|
|
20
|
+
* because the caller surfaces (audit-trail, file-tools) only need
|
|
21
|
+
* the findings list — they do not block writes today (CEO sign-off
|
|
22
|
+
* gate, separate PR).
|
|
23
|
+
*
|
|
24
|
+
* Severity model:
|
|
25
|
+
* The upstream `Block` / `Warn` enum is mirrored as a Pugi field on
|
|
26
|
+
* each finding so a future PR can wire hard-block behavior without
|
|
27
|
+
* re-shaping the call sites.
|
|
28
|
+
*
|
|
29
|
+
* What this is NOT:
|
|
30
|
+
* - An LLM-output safety filter. This scans CONTENT BOUND FOR DISK
|
|
31
|
+
* (audit payloads + file writes / edits) for accidental or
|
|
32
|
+
* adversarial prompt-injection markers.
|
|
33
|
+
* - A secrets scanner. Real secrets detection lives in
|
|
34
|
+
* `scripts/secret-scanner.mjs` (release gate). The few credential
|
|
35
|
+
* heuristics here exist because the upstream Rust treats memory
|
|
36
|
+
* persistence as a credential-exfil surface too.
|
|
37
|
+
*
|
|
38
|
+
* See `licenses/keiseikit-LICENSE-NOTICE.md` for Apache-2.0 attribution.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Maximum captured-match length recorded in a finding. Bounds the
|
|
42
|
+
* worst-case row size in the audit JSONL stream. Set to 128 because
|
|
43
|
+
* the longest legitimate pattern match (`long_base64_line`) would be
|
|
44
|
+
* 1024+ bytes — the operator can re-scan the source content for the
|
|
45
|
+
* full blob if they need it; we only need enough context to triage.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_MATCH_CAPTURE = 128;
|
|
48
|
+
function clampMatch(matched) {
|
|
49
|
+
if (matched.length <= MAX_MATCH_CAPTURE)
|
|
50
|
+
return matched;
|
|
51
|
+
return `${matched.slice(0, MAX_MATCH_CAPTURE)}…`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Invisible / bidi / zero-width unicode codepoints ported verbatim
|
|
55
|
+
* from `INVISIBLE_CHARS` in the upstream Rust. Each one is a known
|
|
56
|
+
* vehicle for hiding prompt-override text from a casual reader.
|
|
57
|
+
*/
|
|
58
|
+
export const INVISIBLE_CHARS = [
|
|
59
|
+
'', // ZERO WIDTH SPACE
|
|
60
|
+
'', // ZERO WIDTH NON-JOINER
|
|
61
|
+
'', // ZERO WIDTH JOINER
|
|
62
|
+
'', // LEFT-TO-RIGHT MARK
|
|
63
|
+
'', // RIGHT-TO-LEFT MARK
|
|
64
|
+
'', // LEFT-TO-RIGHT EMBEDDING
|
|
65
|
+
'', // RIGHT-TO-LEFT EMBEDDING
|
|
66
|
+
'', // POP DIRECTIONAL FORMATTING
|
|
67
|
+
'', // LEFT-TO-RIGHT OVERRIDE
|
|
68
|
+
'', // RIGHT-TO-LEFT OVERRIDE
|
|
69
|
+
'', // WORD JOINER
|
|
70
|
+
'', // BYTE ORDER MARK / ZERO WIDTH NO-BREAK SPACE
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* Pre-built Set for O(1) codepoint membership tests. The scanner walks
|
|
74
|
+
* the input once and probes this set per character — cheaper than a
|
|
75
|
+
* regex with 12 alternation branches.
|
|
76
|
+
*/
|
|
77
|
+
const INVISIBLE_CHAR_SET = new Set(INVISIBLE_CHARS);
|
|
78
|
+
/**
|
|
79
|
+
* Threshold above which a single base64-looking line is flagged.
|
|
80
|
+
* Matches the upstream `BASE64_BLOB_BYTES` constant so the heuristic
|
|
81
|
+
* stays aligned with the Rust spec. The regex below hardcodes the
|
|
82
|
+
* same value for compile-time clarity.
|
|
83
|
+
*/
|
|
84
|
+
export const BASE64_BLOB_BYTES = 1024;
|
|
85
|
+
/**
|
|
86
|
+
* PEM begin marker built at runtime so the literal dashes do not
|
|
87
|
+
* trigger over-eager secret-scanners in this very source file (same
|
|
88
|
+
* concern as the upstream `pem_dashes()` helper).
|
|
89
|
+
*/
|
|
90
|
+
function pemMarker(label) {
|
|
91
|
+
const d = '-'.repeat(5);
|
|
92
|
+
return `${d}BEGIN ${label}${d}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Escape regex metachars in a literal string. We avoid pulling a
|
|
96
|
+
* dependency just for this — the set of metachars is small and
|
|
97
|
+
* well-known.
|
|
98
|
+
*/
|
|
99
|
+
function escapeRegex(literal) {
|
|
100
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Prompt-override patterns. Ported verbatim from
|
|
104
|
+
* `prompt_override_patterns()` in the upstream Rust. The regex
|
|
105
|
+
* strings are the same modulo Rust's `(?im)` inline flags being
|
|
106
|
+
* expressed as `i` + `m` on the TS `RegExp`.
|
|
107
|
+
*/
|
|
108
|
+
const PROMPT_OVERRIDE_PATTERNS = [
|
|
109
|
+
{
|
|
110
|
+
id: 'prompt_override_ignore_previous',
|
|
111
|
+
kind: 'override-prompt',
|
|
112
|
+
re: /ignore\s+previous\s+instructions/gi,
|
|
113
|
+
severity: 'block',
|
|
114
|
+
source: 'promptguard:override',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: 'prompt_override_you_are_now',
|
|
118
|
+
kind: 'override-prompt',
|
|
119
|
+
re: /you\s+are\s+now\b/gi,
|
|
120
|
+
severity: 'block',
|
|
121
|
+
source: 'promptguard:roleplay',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'prompt_override_disregard',
|
|
125
|
+
kind: 'override-prompt',
|
|
126
|
+
re: /disregard\s+(all|prior|above)/gi,
|
|
127
|
+
severity: 'block',
|
|
128
|
+
source: 'promptguard:override',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: 'system_role_prefix',
|
|
132
|
+
kind: 'override-prompt',
|
|
133
|
+
re: /^\s*system\s*:/gim,
|
|
134
|
+
severity: 'block',
|
|
135
|
+
source: 'promptguard:role-prefix',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'chatml_im_start',
|
|
139
|
+
kind: 'tag-injection',
|
|
140
|
+
re: /<\|im_start\|>/g,
|
|
141
|
+
severity: 'block',
|
|
142
|
+
source: 'chatml:tag',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'chatml_endoftext',
|
|
146
|
+
kind: 'tag-injection',
|
|
147
|
+
re: /<\|endoftext\|>/g,
|
|
148
|
+
severity: 'block',
|
|
149
|
+
source: 'chatml:tag',
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
/**
|
|
153
|
+
* Secret-shaped patterns. Ported from `secret_patterns()`. The PEM
|
|
154
|
+
* markers are built at runtime so they do not show up verbatim in
|
|
155
|
+
* this file's bytes (anti-self-trigger).
|
|
156
|
+
*/
|
|
157
|
+
function buildSecretPatterns() {
|
|
158
|
+
const openssh = escapeRegex(pemMarker('OPENSSH PRIVATE KEY'));
|
|
159
|
+
const rsa = escapeRegex(pemMarker('RSA PRIVATE KEY'));
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
id: 'ssh_openssh_private',
|
|
163
|
+
kind: 'secret-marker',
|
|
164
|
+
re: new RegExp(openssh, 'g'),
|
|
165
|
+
severity: 'block',
|
|
166
|
+
source: 'secret:openssh',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'ssh_rsa_private',
|
|
170
|
+
kind: 'secret-marker',
|
|
171
|
+
re: new RegExp(rsa, 'g'),
|
|
172
|
+
severity: 'block',
|
|
173
|
+
source: 'secret:rsa',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
// Upstream P2.1.b audit upgraded this to Block tier — long
|
|
177
|
+
// base64 blobs on a memory-write path are a direct exfil
|
|
178
|
+
// surface for attestation / key blobs pasted into transcripts.
|
|
179
|
+
id: 'long_base64_line',
|
|
180
|
+
kind: 'secret-marker',
|
|
181
|
+
re: new RegExp(`^[A-Za-z0-9+/=]{${BASE64_BLOB_BYTES},}$`, 'gm'),
|
|
182
|
+
severity: 'block',
|
|
183
|
+
source: 'heuristic:base64-blob',
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Substring/heuristic patterns. Ported from `build_substring_table()`.
|
|
189
|
+
* Each row demands ALL needles be present in the LOWERCASED copy of
|
|
190
|
+
* the input (AND semantics) — keeps false-positives low.
|
|
191
|
+
*/
|
|
192
|
+
const SUBSTRING_PATTERNS = [
|
|
193
|
+
{
|
|
194
|
+
id: 'curl_with_bearer',
|
|
195
|
+
kind: 'secret-marker',
|
|
196
|
+
needles: ['bearer ', '://'],
|
|
197
|
+
severity: 'block',
|
|
198
|
+
source: 'exfil:curl-bearer',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'aws_secret_keyword',
|
|
202
|
+
kind: 'secret-marker',
|
|
203
|
+
needles: ['aws_secret'],
|
|
204
|
+
severity: 'block',
|
|
205
|
+
source: 'secret:aws',
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'api_key_url',
|
|
209
|
+
kind: 'secret-marker',
|
|
210
|
+
needles: ['api_key=', '://'],
|
|
211
|
+
severity: 'block',
|
|
212
|
+
source: 'exfil:api-key-url',
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
let REGEX_TABLE = null;
|
|
216
|
+
function regexPatterns() {
|
|
217
|
+
if (REGEX_TABLE === null) {
|
|
218
|
+
REGEX_TABLE = [...PROMPT_OVERRIDE_PATTERNS, ...buildSecretPatterns()];
|
|
219
|
+
}
|
|
220
|
+
return REGEX_TABLE;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Maximum input size we scan. Above this we sample the first
|
|
224
|
+
* MAX_SCAN_BYTES bytes and tag the result as `truncated: true`. This
|
|
225
|
+
* keeps a 10 MB log payload from stalling the audit append path.
|
|
226
|
+
*
|
|
227
|
+
* The threshold is deliberately generous (256 KB) — the typical audit
|
|
228
|
+
* `data` payload is a few hundred bytes (a single `tool_call` envelope)
|
|
229
|
+
* and a file write of an HTML page is well under the cap. The cutoff
|
|
230
|
+
* exists only for pathological cases.
|
|
231
|
+
*/
|
|
232
|
+
export const MAX_SCAN_BYTES = 256 * 1024;
|
|
233
|
+
/**
|
|
234
|
+
* Scan a string for prompt-injection / invisible-unicode / secret
|
|
235
|
+
* markers. Returns the empty array when clean. Never throws —
|
|
236
|
+
* malformed input (e.g. lone surrogates) falls through to the regex
|
|
237
|
+
* engine and produces zero or more findings, never an exception.
|
|
238
|
+
*
|
|
239
|
+
* Pure function. Safe to call from a hot path (audit-trail append,
|
|
240
|
+
* file-tools writeTool) without worrying about side effects.
|
|
241
|
+
*/
|
|
242
|
+
export function scanForInjection(text) {
|
|
243
|
+
if (typeof text !== 'string' || text.length === 0)
|
|
244
|
+
return [];
|
|
245
|
+
const findings = [];
|
|
246
|
+
const scanText = text.length > MAX_SCAN_BYTES ? text.slice(0, MAX_SCAN_BYTES) : text;
|
|
247
|
+
// 1. Invisible unicode scan: O(n) single pass with a Set lookup.
|
|
248
|
+
// We collect per-codepoint hits rather than collapsing them so
|
|
249
|
+
// the operator can see how many bidi marks are present (high
|
|
250
|
+
// counts strongly suggest adversarial intent).
|
|
251
|
+
for (let i = 0; i < scanText.length; i += 1) {
|
|
252
|
+
const ch = scanText[i];
|
|
253
|
+
if (ch === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
if (INVISIBLE_CHAR_SET.has(ch)) {
|
|
256
|
+
const code = ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
|
|
257
|
+
findings.push({
|
|
258
|
+
kind: 'invisible-unicode',
|
|
259
|
+
id: `invisible_unicode_U+${code}`,
|
|
260
|
+
severity: 'warn',
|
|
261
|
+
matched: ch,
|
|
262
|
+
offset: i,
|
|
263
|
+
source: `unicode:invisible:U+${code}`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// 2. Regex table scan. Each pattern uses the `g` flag so we walk
|
|
268
|
+
// every occurrence — a single text can carry multiple ChatML
|
|
269
|
+
// tags or override phrases and the operator needs to see all of
|
|
270
|
+
// them, not just the first.
|
|
271
|
+
for (const pattern of regexPatterns()) {
|
|
272
|
+
// Re-set lastIndex defensively in case a prior call left the
|
|
273
|
+
// regex's stateful cursor mid-string.
|
|
274
|
+
pattern.re.lastIndex = 0;
|
|
275
|
+
let match;
|
|
276
|
+
while ((match = pattern.re.exec(scanText)) !== null) {
|
|
277
|
+
findings.push({
|
|
278
|
+
kind: pattern.kind,
|
|
279
|
+
id: pattern.id,
|
|
280
|
+
severity: pattern.severity,
|
|
281
|
+
matched: clampMatch(match[0]),
|
|
282
|
+
offset: match.index,
|
|
283
|
+
source: pattern.source,
|
|
284
|
+
});
|
|
285
|
+
// Guard against zero-width matches infinite-looping (e.g. a
|
|
286
|
+
// regex that matches the empty string would never advance).
|
|
287
|
+
if (match.index === pattern.re.lastIndex) {
|
|
288
|
+
pattern.re.lastIndex += 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// 3. Substring/heuristic scan. AND semantics: every needle must
|
|
293
|
+
// appear in the lowercased copy. We record the FIRST needle's
|
|
294
|
+
// offset because that is the most actionable index for the
|
|
295
|
+
// operator (the others may be hundreds of bytes away).
|
|
296
|
+
const lower = scanText.toLowerCase();
|
|
297
|
+
for (const pattern of SUBSTRING_PATTERNS) {
|
|
298
|
+
const offsets = pattern.needles.map((n) => lower.indexOf(n));
|
|
299
|
+
if (offsets.every((o) => o >= 0)) {
|
|
300
|
+
const firstOffset = Math.min(...offsets);
|
|
301
|
+
// Reconstruct a useful matched snippet — the needles can be
|
|
302
|
+
// far apart so we cap at the first needle plus a window.
|
|
303
|
+
const snippetEnd = Math.min(firstOffset + MAX_MATCH_CAPTURE, scanText.length);
|
|
304
|
+
findings.push({
|
|
305
|
+
kind: pattern.kind,
|
|
306
|
+
id: pattern.id,
|
|
307
|
+
severity: pattern.severity,
|
|
308
|
+
matched: clampMatch(scanText.slice(firstOffset, snippetEnd)),
|
|
309
|
+
offset: firstOffset,
|
|
310
|
+
source: pattern.source,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return findings;
|
|
315
|
+
}
|
|
316
|
+
export function summarizeFindings(findings) {
|
|
317
|
+
let score = 0;
|
|
318
|
+
const kindSet = new Set();
|
|
319
|
+
for (const f of findings) {
|
|
320
|
+
if (f.severity === 'block')
|
|
321
|
+
score += 1;
|
|
322
|
+
kindSet.add(f.kind);
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
score,
|
|
326
|
+
total: findings.length,
|
|
327
|
+
kinds: Array.from(kindSet).sort(),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Recursively walk a JSON-shaped value and concatenate every string
|
|
332
|
+
* found. Used by audit-trail to fold the entire `data` payload into a
|
|
333
|
+
* single scannable surface — a tool_result with a deeply nested error
|
|
334
|
+
* object could otherwise hide an override prompt one level deep.
|
|
335
|
+
*
|
|
336
|
+
* Cycles are broken by a WeakSet — a payload that round-trips through
|
|
337
|
+
* a session struct is safe to scan even when it has back-references.
|
|
338
|
+
*/
|
|
339
|
+
export function collectStrings(value, seen = new WeakSet()) {
|
|
340
|
+
if (value === null || value === undefined)
|
|
341
|
+
return [];
|
|
342
|
+
if (typeof value === 'string')
|
|
343
|
+
return [value];
|
|
344
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
if (typeof value !== 'object')
|
|
348
|
+
return [];
|
|
349
|
+
if (seen.has(value))
|
|
350
|
+
return [];
|
|
351
|
+
seen.add(value);
|
|
352
|
+
const out = [];
|
|
353
|
+
if (Array.isArray(value)) {
|
|
354
|
+
for (const item of value) {
|
|
355
|
+
out.push(...collectStrings(item, seen));
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
for (const key of Object.keys(value)) {
|
|
360
|
+
// Scan the KEY too — a deliberately-crafted payload could hide
|
|
361
|
+
// an override phrase as an object key.
|
|
362
|
+
out.push(key);
|
|
363
|
+
out.push(...collectStrings(value[key], seen));
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
//# sourceMappingURL=injection-scanner.js.map
|
package/dist/core/settings.js
CHANGED
|
@@ -128,12 +128,175 @@ const pugiSettingsSchema = z.object({
|
|
|
128
128
|
})
|
|
129
129
|
.optional(),
|
|
130
130
|
});
|
|
131
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Wave 7 #20 (2026-05-29) — Claude Code drop-in compat ingest.
|
|
133
|
+
*
|
|
134
|
+
* Operators migrating from Claude Code typically keep a `.claude/`
|
|
135
|
+
* directory at workspace root with settings.json, slash commands,
|
|
136
|
+
* and ambient guidance files. We honour the existence of that
|
|
137
|
+
* directory and mirror the subset of keys that map cleanly onto
|
|
138
|
+
* Pugi's own settings surface — Pugi values ALWAYS win on conflict
|
|
139
|
+
* (the operator opted into Pugi as their primary), CC fills gaps.
|
|
140
|
+
*
|
|
141
|
+
* Opt-out: `PUGI_CC_COMPAT_DISABLE=1` short-circuits the merger and
|
|
142
|
+
* loads only `.pugi/settings.json` (or the empty default).
|
|
143
|
+
*
|
|
144
|
+
* Key mirror table:
|
|
145
|
+
* - `permissions.defaultMode` → `permissions.mode`
|
|
146
|
+
* (CC values map: `acceptEdits|plan|bypassPermissions|default` →
|
|
147
|
+
* `acceptEdits|plan|bypassPermissions|ask`).
|
|
148
|
+
* - `permissions.allow` → `permissions.allow` (concatenated, deduped).
|
|
149
|
+
* - `permissions.deny` → `permissions.deny` (concatenated, deduped).
|
|
150
|
+
* - `enabledPlugins` → ignored (CC-only concept; Pugi has its own
|
|
151
|
+
* plugin surface and we do not want to silently activate them).
|
|
152
|
+
* - `hooks` → currently ignored. Pugi's hook system is
|
|
153
|
+
* managed via `apps/pugi-cli/src/core/hooks/`; future work can
|
|
154
|
+
* wire CC hook entries through that bridge.
|
|
155
|
+
*
|
|
156
|
+
* Unknown CC keys are dropped on the floor by Zod's strip semantics
|
|
157
|
+
* just like the existing PUGI settings path — we never warn on
|
|
158
|
+
* unrecognised CC keys, because the CC surface is wider and we want
|
|
159
|
+
* fallthrough to be silent (operator does not need a stream of "we
|
|
160
|
+
* skipped this CC concept" warnings on every CLI invocation).
|
|
161
|
+
*/
|
|
162
|
+
const ccPermissionsSchema = z
|
|
163
|
+
.object({
|
|
164
|
+
defaultMode: z.string().optional(),
|
|
165
|
+
allow: z.array(z.string()).optional(),
|
|
166
|
+
deny: z.array(z.string()).optional(),
|
|
167
|
+
})
|
|
168
|
+
.passthrough()
|
|
169
|
+
.optional();
|
|
170
|
+
const ccSettingsSchema = z
|
|
171
|
+
.object({
|
|
172
|
+
permissions: ccPermissionsSchema,
|
|
173
|
+
enabledPlugins: z.unknown().optional(),
|
|
174
|
+
hooks: z.unknown().optional(),
|
|
175
|
+
})
|
|
176
|
+
.passthrough();
|
|
177
|
+
/**
|
|
178
|
+
* Env var that disables CC compat ingest entirely. Useful for CI
|
|
179
|
+
* sandboxes where a stray `.claude/` from a parent checkout could
|
|
180
|
+
* otherwise leak permissions into Pugi.
|
|
181
|
+
*/
|
|
182
|
+
export const CC_COMPAT_DISABLE_ENV = 'PUGI_CC_COMPAT_DISABLE';
|
|
183
|
+
/**
|
|
184
|
+
* Map a CC `permissions.defaultMode` to the closest Pugi permission
|
|
185
|
+
* mode. Unknown / missing values map to `undefined` so the caller
|
|
186
|
+
* keeps Pugi's own default.
|
|
187
|
+
*
|
|
188
|
+
* CC's `default` mode = "ask the user for each tool" which is Pugi's
|
|
189
|
+
* `ask` mode. `acceptEdits` / `plan` / `bypassPermissions` map 1:1.
|
|
190
|
+
*/
|
|
191
|
+
export function mapCcPermissionMode(mode) {
|
|
192
|
+
if (typeof mode !== 'string')
|
|
193
|
+
return undefined;
|
|
194
|
+
switch (mode) {
|
|
195
|
+
case 'acceptEdits':
|
|
196
|
+
return 'acceptEdits';
|
|
197
|
+
case 'plan':
|
|
198
|
+
return 'plan';
|
|
199
|
+
case 'bypassPermissions':
|
|
200
|
+
return 'bypassPermissions';
|
|
201
|
+
case 'default':
|
|
202
|
+
return 'ask';
|
|
203
|
+
default:
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Merge a parsed CC settings object into a Pugi settings object.
|
|
209
|
+
* Pugi ALWAYS wins on conflict; CC values fill gaps only.
|
|
210
|
+
*/
|
|
211
|
+
export function mergeCcIntoPugi(pugi, cc, opts) {
|
|
212
|
+
const merged = {
|
|
213
|
+
...pugi,
|
|
214
|
+
permissions: { ...pugi.permissions },
|
|
215
|
+
};
|
|
216
|
+
const pugiWroteMode = pugiPermissionKeyPresent(opts.pugiRawJson, 'mode');
|
|
217
|
+
if (!pugiWroteMode) {
|
|
218
|
+
const ccMode = mapCcPermissionMode(cc.permissions?.defaultMode);
|
|
219
|
+
if (ccMode)
|
|
220
|
+
merged.permissions.mode = ccMode;
|
|
221
|
+
}
|
|
222
|
+
if (Array.isArray(cc.permissions?.allow)) {
|
|
223
|
+
merged.permissions.allow = dedupeKeepFirst([
|
|
224
|
+
...pugi.permissions.allow,
|
|
225
|
+
...cc.permissions.allow,
|
|
226
|
+
]);
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(cc.permissions?.deny)) {
|
|
229
|
+
merged.permissions.deny = dedupeKeepFirst([
|
|
230
|
+
...pugi.permissions.deny,
|
|
231
|
+
...cc.permissions.deny,
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
// `enabledPlugins` and `hooks` are intentionally NOT mirrored. See
|
|
235
|
+
// the doc-block above for rationale.
|
|
236
|
+
void opts.pugiSettingsExisted;
|
|
237
|
+
return merged;
|
|
238
|
+
}
|
|
239
|
+
function pugiPermissionKeyPresent(raw, key) {
|
|
240
|
+
if (!raw || typeof raw !== 'object')
|
|
241
|
+
return false;
|
|
242
|
+
const permissions = raw.permissions;
|
|
243
|
+
if (!permissions || typeof permissions !== 'object')
|
|
244
|
+
return false;
|
|
245
|
+
return Object.prototype.hasOwnProperty.call(permissions, key);
|
|
246
|
+
}
|
|
247
|
+
function dedupeKeepFirst(items) {
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
const out = [];
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
if (seen.has(item))
|
|
252
|
+
continue;
|
|
253
|
+
seen.add(item);
|
|
254
|
+
out.push(item);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Read + parse `.claude/settings.json` at `root`. Returns `undefined`
|
|
260
|
+
* when the file is absent, malformed, or the operator has opted out
|
|
261
|
+
* via `PUGI_CC_COMPAT_DISABLE=1`. Never throws — a broken CC settings
|
|
262
|
+
* file degrades to "no CC compat ingest", not a Pugi boot crash.
|
|
263
|
+
*/
|
|
264
|
+
export function loadCcSettings(root, env = process.env) {
|
|
265
|
+
if (env[CC_COMPAT_DISABLE_ENV] === '1')
|
|
266
|
+
return undefined;
|
|
267
|
+
const ccPath = resolve(root, '.claude/settings.json');
|
|
268
|
+
if (!existsSync(ccPath))
|
|
269
|
+
return undefined;
|
|
270
|
+
let parsed;
|
|
271
|
+
try {
|
|
272
|
+
parsed = JSON.parse(readFileSync(ccPath, 'utf8'));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
const result = ccSettingsSchema.safeParse(parsed);
|
|
278
|
+
if (!result.success)
|
|
279
|
+
return undefined;
|
|
280
|
+
return result.data;
|
|
281
|
+
}
|
|
282
|
+
export function loadSettings(root, env = process.env) {
|
|
132
283
|
const settingsPath = resolve(root, '.pugi/settings.json');
|
|
133
|
-
|
|
134
|
-
|
|
284
|
+
const pugiExists = existsSync(settingsPath);
|
|
285
|
+
let pugiRawJson = undefined;
|
|
286
|
+
let pugi;
|
|
287
|
+
if (pugiExists) {
|
|
288
|
+
pugiRawJson = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
289
|
+
pugi = pugiSettingsSchema.parse(pugiRawJson);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
pugi = pugiSettingsSchema.parse({});
|
|
135
293
|
}
|
|
136
|
-
const
|
|
137
|
-
|
|
294
|
+
const cc = loadCcSettings(root, env);
|
|
295
|
+
if (!cc)
|
|
296
|
+
return pugi;
|
|
297
|
+
return mergeCcIntoPugi(pugi, cc, {
|
|
298
|
+
pugiSettingsExisted: pugiExists,
|
|
299
|
+
pugiRawJson,
|
|
300
|
+
});
|
|
138
301
|
}
|
|
139
302
|
//# sourceMappingURL=settings.js.map
|
package/dist/runtime/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { realpathSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { dirname, relative, resolve } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { linkArtifact } from '../core/format/osc8-link.js';
|
|
8
9
|
import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
|
|
9
10
|
import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
|
|
10
11
|
import { loadMcpRegistry } from '../core/mcp/registry.js';
|
|
@@ -4663,10 +4664,46 @@ function runEngineTask(kind) {
|
|
|
4663
4664
|
});
|
|
4664
4665
|
const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
|
|
4665
4666
|
const taskId = `${kind}-${Date.now()}`;
|
|
4667
|
+
// CEO P1 #25 (2026-05-29) — Ctrl+C interrupt for non-REPL
|
|
4668
|
+
// dispatch (CC parity). Before this fix, SIGINT on `pugi code "..."`
|
|
4669
|
+
// killed the whole CLI process so the operator could not abort a
|
|
4670
|
+
// single bad turn without losing the session. We now:
|
|
4671
|
+
// 1. Bind an AbortController to the engine run so the first
|
|
4672
|
+
// Ctrl+C aborts THIS dispatch (the engine loop already
|
|
4673
|
+
// honours `ctx.signal` — see EngineContext in @pugi/sdk).
|
|
4674
|
+
// 2. Count presses: a second Ctrl+C within 2s falls through к
|
|
4675
|
+
// the legacy "hard exit" so the operator always has an
|
|
4676
|
+
// escape hatch (mirrors the REPL InputBox "press again к
|
|
4677
|
+
// exit" pattern).
|
|
4678
|
+
// 3. Restore Node's default SIGINT semantics in the `finally`
|
|
4679
|
+
// block so successive engine runs (e.g. within tests that
|
|
4680
|
+
// iterate runEngineTask) each see a fresh handler.
|
|
4681
|
+
const abortController = new AbortController();
|
|
4682
|
+
const SIGINT_DOUBLE_PRESS_WINDOW_MS = 2000;
|
|
4683
|
+
let firstSigintAt = null;
|
|
4684
|
+
const onSigint = () => {
|
|
4685
|
+
const now = Date.now();
|
|
4686
|
+
if (firstSigintAt !== null && now - firstSigintAt <= SIGINT_DOUBLE_PRESS_WINDOW_MS) {
|
|
4687
|
+
// Second press within the window — hard exit. Mirrors the
|
|
4688
|
+
// shell convention (^C ^C = give up) so the operator never
|
|
4689
|
+
// gets stuck on a runaway engine.
|
|
4690
|
+
process.stderr.write(`\npugi ${label}: interrupted (hard exit on ^C^C).\n`);
|
|
4691
|
+
process.exit(130);
|
|
4692
|
+
}
|
|
4693
|
+
firstSigintAt = now;
|
|
4694
|
+
if (!abortController.signal.aborted) {
|
|
4695
|
+
process.stderr.write(`\npugi ${label}: aborting current turn (press ^C again to exit).\n`);
|
|
4696
|
+
abortController.abort();
|
|
4697
|
+
}
|
|
4698
|
+
};
|
|
4699
|
+
process.on('SIGINT', onSigint);
|
|
4666
4700
|
// β4 r2 P1 #3 — try/finally so loaded MCP child processes are
|
|
4667
4701
|
// reaped regardless of run outcome (success, blocked, failed,
|
|
4668
|
-
// thrown).
|
|
4669
|
-
//
|
|
4702
|
+
// thrown). Triple-review P1 (#725): the try now wraps BOTH the
|
|
4703
|
+
// adapter.run() call и the iteration, so a sync throw from
|
|
4704
|
+
// adapter.run() still hits the SIGINT-detach + MCP-cleanup
|
|
4705
|
+
// finally block. Before this fix a sync throw would have leaked
|
|
4706
|
+
// the SIGINT listener (10+ runs → MaxListenersExceededWarning).
|
|
4670
4707
|
try {
|
|
4671
4708
|
const events = adapter.run({
|
|
4672
4709
|
id: taskId,
|
|
@@ -4680,7 +4717,7 @@ function runEngineTask(kind) {
|
|
|
4680
4717
|
// executor refusal sentinel). The permission mode here is the
|
|
4681
4718
|
// workspace-level toggle and is unchanged from interactive default.
|
|
4682
4719
|
permissionMode: 'auto',
|
|
4683
|
-
}, { sessionId: session.id });
|
|
4720
|
+
}, { sessionId: session.id, signal: abortController.signal });
|
|
4684
4721
|
const statusEvents = [];
|
|
4685
4722
|
let result = null;
|
|
4686
4723
|
for await (const event of events) {
|
|
@@ -4896,8 +4933,13 @@ function runEngineTask(kind) {
|
|
|
4896
4933
|
textLines.push(`Summary: ${result.summary}`);
|
|
4897
4934
|
if (result.filesChanged.length > 0) {
|
|
4898
4935
|
textLines.push(`Files modified (${result.filesChanged.length}):`);
|
|
4899
|
-
|
|
4900
|
-
|
|
4936
|
+
// CEO P2 #38 (Wave 7 artifact linking): emit each file as an OSC 8
|
|
4937
|
+
// hyperlink so modern terminals (iTerm2, kitty, VS Code, Windows
|
|
4938
|
+
// Terminal, Alacritty, WezTerm) let the operator click straight
|
|
4939
|
+
// into the file. Falls back to plain text on dumb terminals / CI.
|
|
4940
|
+
for (const file of result.filesChanged) {
|
|
4941
|
+
textLines.push(` - ${linkArtifact(file, { workspaceRoot: process.cwd() })}`);
|
|
4942
|
+
}
|
|
4901
4943
|
}
|
|
4902
4944
|
else if (kind !== 'explain' && kind !== 'plan') {
|
|
4903
4945
|
textLines.push('Files modified: none');
|
|
@@ -4925,6 +4967,13 @@ function runEngineTask(kind) {
|
|
|
4925
4967
|
writeOutput(flags, payload, textLines.join('\n'));
|
|
4926
4968
|
}
|
|
4927
4969
|
finally {
|
|
4970
|
+
// CEO P1 #25 — detach the per-run SIGINT handler so a second
|
|
4971
|
+
// engine run from the same process (tests, scripts iterating
|
|
4972
|
+
// `runEngineTask`, future REPL non-watch dispatches) starts
|
|
4973
|
+
// with a fresh press counter and does NOT pile up listeners
|
|
4974
|
+
// on the process object (Node prints
|
|
4975
|
+
// `MaxListenersExceededWarning` at 10).
|
|
4976
|
+
process.off('SIGINT', onSigint);
|
|
4928
4977
|
// β4 r2 P1 #3 — tear down live MCP child processes BEFORE the
|
|
4929
4978
|
// CLI exits. shutdown() is idempotent and swallows per-server
|
|
4930
4979
|
// disconnect errors, so it is safe even if no servers connected.
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.55');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/dist/tools/bash.js
CHANGED
|
@@ -85,6 +85,7 @@ export async function bashTool(input, ctx) {
|
|
|
85
85
|
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
86
86
|
truncated: false,
|
|
87
87
|
timedOut: false,
|
|
88
|
+
cancelled: false,
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
// Permission gate via the new class-aware engine.
|
|
@@ -119,6 +120,25 @@ export async function bashTool(input, ctx) {
|
|
|
119
120
|
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
120
121
|
truncated: false,
|
|
121
122
|
timedOut: false,
|
|
123
|
+
cancelled: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// CEO P1 #25 (2026-05-29) — pre-spawn cancellation check. Fires
|
|
127
|
+
// AFTER the permission gate so a cancelled brief never reaches
|
|
128
|
+
// /bin/sh even when the command would have been allowed. Mirrors
|
|
129
|
+
// the `gateOnCancellation` pattern from file-tools.ts.
|
|
130
|
+
if (ctx.cancellation?.isAborted === true) {
|
|
131
|
+
const reason = 'operator_aborted: bash refused before spawn';
|
|
132
|
+
emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'pre_spawn' });
|
|
133
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
134
|
+
return {
|
|
135
|
+
stdout: '',
|
|
136
|
+
stderr: reason,
|
|
137
|
+
exitCode: 130,
|
|
138
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
139
|
+
truncated: false,
|
|
140
|
+
timedOut: false,
|
|
141
|
+
cancelled: true,
|
|
122
142
|
};
|
|
123
143
|
}
|
|
124
144
|
// Background job branch.
|
|
@@ -147,6 +167,12 @@ export async function bashTool(input, ctx) {
|
|
|
147
167
|
// before the timeout watchdog fires, we enforce a live ceiling
|
|
148
168
|
// (BASH_LIVE_OUTPUT_CAP_BYTES) and SIGTERM the child when crossed.
|
|
149
169
|
let truncatedMidStream = false;
|
|
170
|
+
// CEO P1 #25 (2026-05-29) — mid-stream operator cancellation. The
|
|
171
|
+
// listener registered against the CancellationToken below flips
|
|
172
|
+
// this flag and SIGTERMs the child. The close handler reads it to
|
|
173
|
+
// decide between `cancelled` (operator abort) and `timedOut`
|
|
174
|
+
// (watchdog).
|
|
175
|
+
let cancelledMidStream = false;
|
|
150
176
|
const enforceLiveCap = () => {
|
|
151
177
|
if (truncatedMidStream)
|
|
152
178
|
return;
|
|
@@ -160,21 +186,88 @@ export async function bashTool(input, ctx) {
|
|
|
160
186
|
// child already exited; the close handler will run
|
|
161
187
|
}
|
|
162
188
|
};
|
|
189
|
+
// CEO P1 #25 (2026-05-29) — live stream callback. When the REPL
|
|
190
|
+
// host wires `onStreamChunk`, we forward each stdout/stderr chunk
|
|
191
|
+
// in real time so the conversation pane / tool-stream pane paint
|
|
192
|
+
// bytes as they arrive instead of waiting for the child to exit.
|
|
193
|
+
// We invoke the callback inside a try/catch so a buggy sink
|
|
194
|
+
// (renderer crash, assertion error) never escalates to killing
|
|
195
|
+
// the bash dispatch. The buffered path below still captures the
|
|
196
|
+
// chunk so the model + audit trail stay consistent regardless of
|
|
197
|
+
// renderer health.
|
|
198
|
+
const onStreamChunk = ctx.onStreamChunk;
|
|
199
|
+
const emitStreamChunk = onStreamChunk
|
|
200
|
+
? (stream, chunk) => {
|
|
201
|
+
try {
|
|
202
|
+
onStreamChunk({ stream, data: chunk.toString('utf8') });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Sink crash — swallow.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
: null;
|
|
163
209
|
child.stdout?.on('data', (chunk) => {
|
|
164
|
-
if (truncatedMidStream)
|
|
210
|
+
if (truncatedMidStream || cancelledMidStream)
|
|
165
211
|
return;
|
|
166
212
|
stdoutChunks.push(chunk);
|
|
167
213
|
stdoutBytes += chunk.length;
|
|
214
|
+
if (emitStreamChunk)
|
|
215
|
+
emitStreamChunk('stdout', chunk);
|
|
168
216
|
enforceLiveCap();
|
|
169
217
|
});
|
|
170
218
|
child.stderr?.on('data', (chunk) => {
|
|
171
|
-
if (truncatedMidStream)
|
|
219
|
+
if (truncatedMidStream || cancelledMidStream)
|
|
172
220
|
return;
|
|
173
221
|
stderrChunks.push(chunk);
|
|
174
222
|
stderrBytes += chunk.length;
|
|
223
|
+
if (emitStreamChunk)
|
|
224
|
+
emitStreamChunk('stderr', chunk);
|
|
175
225
|
enforceLiveCap();
|
|
176
226
|
});
|
|
227
|
+
// CEO P1 #25 — wire the cancellation token to SIGTERM. We track
|
|
228
|
+
// the detach handle so a successful run releases the listener
|
|
229
|
+
// instead of leaving it pinned to a long-lived REPL
|
|
230
|
+
// CancellationToken (same anti-leak pattern as
|
|
231
|
+
// native-pugi.ts:262).
|
|
232
|
+
let detachCancelListener;
|
|
233
|
+
if (ctx.cancellation && !ctx.cancellation.isAborted) {
|
|
234
|
+
const onAbort = () => {
|
|
235
|
+
if (cancelledMidStream)
|
|
236
|
+
return;
|
|
237
|
+
cancelledMidStream = true;
|
|
238
|
+
emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'mid_stream' });
|
|
239
|
+
try {
|
|
240
|
+
child.kill('SIGTERM');
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// child already exited; close handler will run
|
|
244
|
+
}
|
|
245
|
+
// SIGKILL escalation if the child does not honour SIGTERM
|
|
246
|
+
// within the grace window. Mirrors the timeout watchdog's
|
|
247
|
+
// two-phase shutdown.
|
|
248
|
+
setTimeout(() => {
|
|
249
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
250
|
+
return;
|
|
251
|
+
try {
|
|
252
|
+
child.kill('SIGKILL');
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// gone between the check and the signal
|
|
256
|
+
}
|
|
257
|
+
}, BASH_SIGKILL_GRACE_MS).unref();
|
|
258
|
+
};
|
|
259
|
+
detachCancelListener = ctx.cancellation.onAbort(onAbort);
|
|
260
|
+
}
|
|
177
261
|
const timeoutOutcome = await waitWithTimeout(child, timeoutMs);
|
|
262
|
+
// Detach the cancellation listener on completion so a long-lived
|
|
263
|
+
// REPL token does not retain a reference to the dead child + this
|
|
264
|
+
// closure.
|
|
265
|
+
if (detachCancelListener) {
|
|
266
|
+
try {
|
|
267
|
+
detachCancelListener();
|
|
268
|
+
}
|
|
269
|
+
catch { /* listener already drained */ }
|
|
270
|
+
}
|
|
178
271
|
const stdoutFull = Buffer.concat(stdoutChunks).toString('utf8');
|
|
179
272
|
const stderrFull = Buffer.concat(stderrChunks).toString('utf8');
|
|
180
273
|
const combinedBytes = stdoutBytes + stderrBytes;
|
|
@@ -199,6 +292,25 @@ export async function bashTool(input, ctx) {
|
|
|
199
292
|
stdoutOut = capToCombined(stdoutFull, stderrFull).stdout;
|
|
200
293
|
stderrOut = capToCombined(stdoutFull, stderrFull).stderr;
|
|
201
294
|
}
|
|
295
|
+
// CEO P1 #25 — cancellation wins races against timeout / cap
|
|
296
|
+
// overflow. The token already aborted by the time the close
|
|
297
|
+
// handler fires; we distinguish operator-driven termination from
|
|
298
|
+
// the watchdog so the REPL transcript reads "Aborted." rather
|
|
299
|
+
// than "Timed out."
|
|
300
|
+
if (cancelledMidStream) {
|
|
301
|
+
const reason = 'operator_aborted: bash killed mid-stream';
|
|
302
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
303
|
+
return {
|
|
304
|
+
stdout: stdoutOut,
|
|
305
|
+
stderr: stderrOut === '' ? reason : `${stderrOut}\n${reason}`,
|
|
306
|
+
exitCode: 130,
|
|
307
|
+
artifactRef,
|
|
308
|
+
nextCwd,
|
|
309
|
+
truncated,
|
|
310
|
+
timedOut: false,
|
|
311
|
+
cancelled: true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
202
314
|
if (truncatedMidStream) {
|
|
203
315
|
// We killed the child because output cap exceeded mid-stream.
|
|
204
316
|
// Report that as the failure cause rather than as a timeout —
|
|
@@ -217,6 +329,7 @@ export async function bashTool(input, ctx) {
|
|
|
217
329
|
nextCwd,
|
|
218
330
|
truncated: true,
|
|
219
331
|
timedOut: false,
|
|
332
|
+
cancelled: false,
|
|
220
333
|
};
|
|
221
334
|
}
|
|
222
335
|
if (timeoutOutcome.timedOut) {
|
|
@@ -230,6 +343,7 @@ export async function bashTool(input, ctx) {
|
|
|
230
343
|
nextCwd,
|
|
231
344
|
truncated,
|
|
232
345
|
timedOut: true,
|
|
346
|
+
cancelled: false,
|
|
233
347
|
};
|
|
234
348
|
}
|
|
235
349
|
const exitCode = timeoutOutcome.exitCode;
|
|
@@ -242,6 +356,7 @@ export async function bashTool(input, ctx) {
|
|
|
242
356
|
nextCwd,
|
|
243
357
|
truncated,
|
|
244
358
|
timedOut: false,
|
|
359
|
+
cancelled: false,
|
|
245
360
|
};
|
|
246
361
|
}
|
|
247
362
|
function sanitizeTimeout(value) {
|
|
@@ -471,6 +586,7 @@ function runBackground(input) {
|
|
|
471
586
|
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
472
587
|
truncated: false,
|
|
473
588
|
timedOut: false,
|
|
589
|
+
cancelled: false,
|
|
474
590
|
};
|
|
475
591
|
}
|
|
476
592
|
/**
|
|
@@ -807,6 +923,7 @@ export function bashToolSync(input, ctx) {
|
|
|
807
923
|
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
808
924
|
truncated: false,
|
|
809
925
|
timedOut: false,
|
|
926
|
+
cancelled: false,
|
|
810
927
|
};
|
|
811
928
|
}
|
|
812
929
|
const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
|
|
@@ -838,6 +955,27 @@ export function bashToolSync(input, ctx) {
|
|
|
838
955
|
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
839
956
|
truncated: false,
|
|
840
957
|
timedOut: false,
|
|
958
|
+
cancelled: false,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
// CEO P1 #25 — sync path observes pre-spawn cancellation too. The
|
|
962
|
+
// sync path is used by the engine-loop tool-bridge (`bashToolSync`
|
|
963
|
+
// from tool-bridge.ts:1385); we cannot mid-stream cancel that path
|
|
964
|
+
// without rewriting spawnSync, but the pre-spawn gate still gives
|
|
965
|
+
// the operator a quick-exit window between permission and shell
|
|
966
|
+
// launch.
|
|
967
|
+
if (ctx.cancellation?.isAborted === true) {
|
|
968
|
+
const reason = 'operator_aborted: bash refused before spawn';
|
|
969
|
+
emitEvent(ctx.session, 'bash.cancelled', { cmd, phase: 'pre_spawn_sync' });
|
|
970
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
971
|
+
return {
|
|
972
|
+
stdout: '',
|
|
973
|
+
stderr: reason,
|
|
974
|
+
exitCode: 130,
|
|
975
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
976
|
+
truncated: false,
|
|
977
|
+
timedOut: false,
|
|
978
|
+
cancelled: true,
|
|
841
979
|
};
|
|
842
980
|
}
|
|
843
981
|
const timeoutMs = sanitizeTimeout(input.timeoutMs);
|
|
@@ -885,6 +1023,7 @@ export function bashToolSync(input, ctx) {
|
|
|
885
1023
|
nextCwd,
|
|
886
1024
|
truncated,
|
|
887
1025
|
timedOut,
|
|
1026
|
+
cancelled: false,
|
|
888
1027
|
};
|
|
889
1028
|
}
|
|
890
1029
|
//# sourceMappingURL=bash.js.map
|
package/dist/tools/file-tools.js
CHANGED
|
@@ -33,6 +33,7 @@ import { globSync } from 'node:fs';
|
|
|
33
33
|
import { decidePermission } from '../core/permission.js';
|
|
34
34
|
import { StaleReadError, createReadRecord, hashContent, } from '../core/file-cache.js';
|
|
35
35
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
36
|
+
import { scanForInjection, summarizeFindings } from '../core/security/injection-scanner.js';
|
|
36
37
|
import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
|
|
37
38
|
/**
|
|
38
39
|
* α6.9 WriteGate marker — thrown by `gateOnCancellation` when the
|
|
@@ -184,6 +185,14 @@ export function writeTool(ctx, path, content) {
|
|
|
184
185
|
const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
|
|
185
186
|
writeFileSync(tmp, content, { encoding: 'utf8', mode: 0o600 });
|
|
186
187
|
renameSync(tmp, resolved);
|
|
188
|
+
// Injection scan (ported KeiSeiKit `injection_patterns.rs`,
|
|
189
|
+
// Apache-2.0). Scan the BODY (never the path — path security is
|
|
190
|
+
// owned by `path-security.ts`). Findings are SURFACED as an extra
|
|
191
|
+
// line on the session tool-result, never block the write. Hard-
|
|
192
|
+
// block requires a separate CEO-signed PR. Failure here must NOT
|
|
193
|
+
// throw: a buggy scanner cannot rugpull the write that already
|
|
194
|
+
// landed on disk above.
|
|
195
|
+
surfaceInjectionWarning(ctx, toolCallId, 'write', path, content);
|
|
187
196
|
// Refresh the cache with the post-write content so the model can
|
|
188
197
|
// chain a follow-up read+edit on the same file without an extra
|
|
189
198
|
// round-trip. Same pattern editTool uses below.
|
|
@@ -197,6 +206,36 @@ export function writeTool(ctx, path, content) {
|
|
|
197
206
|
});
|
|
198
207
|
recordToolResult(ctx.session, toolCallId, 'success', `${existed ? 'Updated' : 'Created'} ${path}`);
|
|
199
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Surface an injection-scan warning on a file write/edit BODY. The
|
|
211
|
+
* scan never blocks — it folds findings into the session as a
|
|
212
|
+
* `tool_result` with status `warn` so an operator (or SOC pipeline
|
|
213
|
+
* tailing `<workspace>/.pugi/events.jsonl`) sees the signal without a
|
|
214
|
+
* mid-dispatch rollback.
|
|
215
|
+
*
|
|
216
|
+
* Wrapped in try/catch so a malformed scanner never crashes the tool
|
|
217
|
+
* loop — the write itself has already landed on disk by the time we
|
|
218
|
+
* call this.
|
|
219
|
+
*/
|
|
220
|
+
function surfaceInjectionWarning(ctx, triggeringToolCallId, tool, path, body) {
|
|
221
|
+
try {
|
|
222
|
+
const findings = scanForInjection(body);
|
|
223
|
+
if (findings.length === 0)
|
|
224
|
+
return;
|
|
225
|
+
const summary = summarizeFindings(findings);
|
|
226
|
+
const warnCallId = recordToolCall(ctx.session, 'injection_warning', path);
|
|
227
|
+
const message = `injection_warning: ${tool} ${path} — ${summary.total} pattern(s) ` +
|
|
228
|
+
`(score=${summary.score}, kinds=${summary.kinds.join('|')}). ` +
|
|
229
|
+
`Triggering call: ${triggeringToolCallId}. ` +
|
|
230
|
+
`Detector: keiseikit-injection-patterns. Write was NOT blocked.`;
|
|
231
|
+
recordToolResult(ctx.session, warnCallId, 'success', message);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Scanner failure must NEVER throw — the write has already
|
|
235
|
+
// landed and the tool loop has to continue. Silent no-op
|
|
236
|
+
// mirrors the audit-trail contract.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
200
239
|
export function editTool(ctx, path, oldString, newString) {
|
|
201
240
|
const toolCallId = recordToolCall(ctx.session, 'edit', path);
|
|
202
241
|
// α6.9 WriteGate: refuse the edit when the operator has cancelled
|
|
@@ -252,6 +291,13 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
252
291
|
const tmp = `${resolved}.pugi-tmp-${Date.now()}`;
|
|
253
292
|
writeFileSync(tmp, after, { encoding: 'utf8', mode: 0o600 });
|
|
254
293
|
renameSync(tmp, resolved);
|
|
294
|
+
// Injection scan (ported KeiSeiKit `injection_patterns.rs`,
|
|
295
|
+
// Apache-2.0). We scan the NEW SUBSTRING the model is inserting,
|
|
296
|
+
// not the full post-edit file — the rest of the file is operator-
|
|
297
|
+
// owned content that pre-dates this dispatch. False-positive on
|
|
298
|
+
// legitimate prose that mentions banned phrases is the worst
|
|
299
|
+
// outcome and the warn-only contract bounds the cost.
|
|
300
|
+
surfaceInjectionWarning(ctx, toolCallId, 'edit', path, newString);
|
|
255
301
|
ctx.readCache.set(createReadRecord(ctx.root, path, after, 'read_tool'));
|
|
256
302
|
recordFileMutation(ctx.session, {
|
|
257
303
|
toolCallId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.55",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"undici": "^8.3.0",
|
|
56
56
|
"zod": "^3.23.0",
|
|
57
57
|
"@pugi/personas": "0.1.2",
|
|
58
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
58
|
+
"@pugi/sdk": "0.1.0-beta.55"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^22.0.0",
|