@lousy-agents/agent-shell 5.8.7 → 5.9.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.
package/dist/index.js CHANGED
@@ -425,6 +425,8 @@ const external_node_child_process_namespaceObject = __rspack_createRequire_requi
425
425
  const external_node_crypto_namespaceObject = __rspack_createRequire_require("node:crypto");
426
426
  ;// CONCATENATED MODULE: external "node:fs/promises"
427
427
  const promises_namespaceObject = __rspack_createRequire_require("node:fs/promises");
428
+ ;// CONCATENATED MODULE: external "node:readline"
429
+ const external_node_readline_namespaceObject = __rspack_createRequire_require("node:readline");
428
430
  ;// CONCATENATED MODULE: external "node:path"
429
431
  const external_node_path_namespaceObject = __rspack_createRequire_require("node:path");
430
432
  ;// CONCATENATED MODULE: ./src/git-utils.ts
@@ -490,73 +492,6 @@ function defaultExecutor(env) {
490
492
  }
491
493
  /** Process-scoped singleton for production use. */ const git_utils_getRepositoryRoot = createGetRepositoryRoot();
492
494
 
493
- ;// CONCATENATED MODULE: external "node:fs"
494
- const external_node_fs_namespaceObject = __rspack_createRequire_require("node:fs");
495
- ;// CONCATENATED MODULE: external "node:readline"
496
- const external_node_readline_namespaceObject = __rspack_createRequire_require("node:readline");
497
- ;// CONCATENATED MODULE: ./src/log/format.ts
498
- function formatDuration(ms) {
499
- if (ms < 1000) return `${Math.round(ms)}ms`;
500
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
501
- return `${(ms / 60000).toFixed(1)}m`;
502
- }
503
- function formatTimestamp(iso) {
504
- const d = new Date(iso);
505
- return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
506
- }
507
- function truncateCommand(command, maxLen) {
508
- if (command.length <= maxLen) return command;
509
- return `${command.slice(0, maxLen - 3)}...`;
510
- }
511
- function padRight(str, width) {
512
- if (str.length >= width) return str;
513
- return str + " ".repeat(width - str.length);
514
- }
515
- const COL_TIMESTAMP = 21;
516
- const COL_SCRIPT = 9;
517
- const COL_ACTOR = 13;
518
- const COL_EXIT = 6;
519
- const COL_DURATION = 10;
520
- const MAX_COMMAND_LEN = 50;
521
- function formatEventsTable(events) {
522
- const header = padRight("TIMESTAMP", COL_TIMESTAMP) + padRight("SCRIPT", COL_SCRIPT) + padRight("ACTOR", COL_ACTOR) + padRight("EXIT", COL_EXIT) + padRight("DURATION", COL_DURATION) + "COMMAND";
523
- const rows = events.map((event)=>{
524
- const timestamp = formatTimestamp(event.timestamp);
525
- const script = event.event === "script_end" ? event.script ?? "-" : "-";
526
- const actor = event.actor;
527
- const exitCode = event.event === "script_end" ? String(event.exit_code) : "-";
528
- const duration = event.event === "script_end" ? formatDuration(event.duration_ms) : "-";
529
- const command = truncateCommand(event.command, MAX_COMMAND_LEN);
530
- return padRight(timestamp, COL_TIMESTAMP) + padRight(script, COL_SCRIPT) + padRight(actor, COL_ACTOR) + padRight(exitCode, COL_EXIT) + padRight(duration, COL_DURATION) + command;
531
- });
532
- return [
533
- header,
534
- ...rows
535
- ].join("\n");
536
- }
537
- function formatSessionsTable(sessions) {
538
- const colSession = 11;
539
- const colFirstEvent = 21;
540
- const colLastEvent = 21;
541
- const colEvents = 9;
542
- const header = padRight("SESSION", colSession) + padRight("FIRST EVENT", colFirstEvent) + padRight("LAST EVENT", colLastEvent) + padRight("EVENTS", colEvents) + "ACTORS";
543
- const rows = sessions.map((s)=>{
544
- const sessionTrunc = s.sessionId.slice(0, 8);
545
- const firstEvent = formatTimestamp(s.firstEvent);
546
- const lastEvent = formatTimestamp(s.lastEvent);
547
- const eventCount = String(s.eventCount);
548
- const actors = s.actors.join(", ");
549
- return padRight(sessionTrunc, colSession) + padRight(firstEvent, colFirstEvent) + padRight(lastEvent, colLastEvent) + padRight(eventCount, colEvents) + actors;
550
- });
551
- return [
552
- header,
553
- ...rows
554
- ].join("\n");
555
- }
556
- function formatEventsJson(events) {
557
- return JSON.stringify(events, null, 2);
558
- }
559
-
560
495
  ;// CONCATENATED MODULE: ./src/path-utils.ts
561
496
 
562
497
  function isWithinProjectRoot(resolvedPath, projectRoot) {
@@ -14310,2031 +14245,2710 @@ core_config(en());
14310
14245
 
14311
14246
  /* export default */ const v4 = ((/* unused pure expression or super */ null && (z4)));
14312
14247
 
14313
- ;// CONCATENATED MODULE: ./src/types.ts
14314
- // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
14248
+ ;// CONCATENATED MODULE: ./src/sanitize.ts
14249
+ /**
14250
+ * Escapes ASCII and C1 control characters in error messages before writing
14251
+ * to stderr. Replaces each control character with its `\xNN` hex
14252
+ * representation to prevent log/terminal injection when errors embed
14253
+ * untrusted data.
14254
+ */ function sanitizeForStderr(err) {
14255
+ const msg = err instanceof Error ? err.message : String(err);
14256
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
14257
+ return msg.replace(/[\u0000-\u001f\u007f-\u009f]/g, (ch)=>{
14258
+ return `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
14259
+ });
14260
+ }
14261
+ /**
14262
+ * Escapes ASCII and C1 control characters (except newline) in output text.
14263
+ * Like sanitizeForStderr but preserves newlines for JSON formatting.
14264
+ */ function sanitizeOutput(text) {
14265
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
14266
+ return text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, (ch)=>{
14267
+ return `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
14268
+ });
14269
+ }
14270
+ /**
14271
+ * Sanitizes untrusted values before embedding in prompts.
14272
+ * Strips newlines and all backticks that could inject instructions,
14273
+ * and truncates to a safe length.
14274
+ */ function sanitizePromptValue(value) {
14275
+ return value.replace(/[\n\r]/g, " ").replace(/`/g, "").slice(0, 256);
14276
+ }
14277
+ /**
14278
+ * Shell metacharacters that indicate compound or piped commands.
14279
+ * Commands containing these are excluded from the allow list because
14280
+ * they could mask injection (e.g. `npm test && curl evil`).
14281
+ */ const SHELL_METACHAR_PATTERN = /[;|&`><$()\\\n\r]/;
14282
+ /**
14283
+ * Returns true if the command is non-empty, contains no shell metacharacters,
14284
+ * and is safe to include in a policy allow list.
14285
+ */ function isSafeCommand(command) {
14286
+ const normalized = command.trim();
14287
+ if (normalized.length === 0) {
14288
+ return false;
14289
+ }
14290
+ return !SHELL_METACHAR_PATTERN.test(normalized);
14291
+ }
14315
14292
 
14316
- const SCHEMA_VERSION = 1;
14317
- const baseFields = {
14318
- v: literal(1),
14319
- session_id: schemas_string(),
14320
- command: schemas_string(),
14321
- actor: schemas_string(),
14322
- timestamp: schemas_string(),
14323
- env: record(schemas_string(), schemas_string()),
14324
- tags: record(schemas_string(), schemas_string())
14325
- };
14326
- const ScriptEndEventSchema = schemas_object({
14327
- ...baseFields,
14328
- event: literal("script_end"),
14329
- script: schemas_string().optional(),
14330
- package: schemas_string().optional(),
14331
- package_version: schemas_string().optional(),
14332
- exit_code: schemas_number().int(),
14333
- signal: schemas_string().nullable(),
14334
- duration_ms: schemas_number()
14335
- }).strict();
14336
- const ShimErrorEventSchema = schemas_object({
14337
- ...baseFields,
14338
- event: literal("shim_error")
14339
- }).strict();
14340
- const PolicyDecisionEventSchema = schemas_object({
14341
- ...baseFields,
14342
- event: literal("policy_decision"),
14343
- decision: schemas_enum([
14344
- "allow",
14345
- "deny"
14346
- ]),
14347
- matched_rule: schemas_string().nullable()
14348
- }).strict();
14349
- const ScriptEventSchema = discriminatedUnion("event", [
14350
- ScriptEndEventSchema,
14351
- ShimErrorEventSchema,
14352
- PolicyDecisionEventSchema
14353
- ]);
14354
- const MAX_POLICY_RULES = 10_000;
14355
- const MAX_RULE_LENGTH = 1024;
14356
- const policyRuleArray = schemas_array(schemas_string().max(MAX_RULE_LENGTH)).max(MAX_POLICY_RULES);
14357
- const PolicyConfigSchema = schemas_object({
14358
- allow: policyRuleArray.optional(),
14359
- deny: policyRuleArray.default(()=>[])
14360
- }).strict();
14361
- // NOTE: This schema mirrors CopilotHookCommandSchema in @lousy-agents/core
14362
- // (packages/core/src/entities/copilot-hook-schema.ts). Keep them aligned.
14363
- // agent-shell cannot import from core since it is a standalone published binary.
14364
- const MAX_HOOKS_PER_EVENT = 100;
14365
- /** Regex that allows standard env var names and rejects __proto__ (the prototype-polluting key). */ const ENV_KEY_PATTERN = /^(?!__proto__$)[a-zA-Z_][a-zA-Z0-9_]*$/;
14366
- const HookCommandSchema = schemas_object({
14367
- type: literal("command"),
14368
- bash: schemas_string().min(1, "Hook bash command must not be empty").optional(),
14369
- powershell: schemas_string().min(1, "Hook PowerShell command must not be empty").optional(),
14370
- cwd: schemas_string().optional(),
14371
- timeoutSec: schemas_number().positive().optional(),
14372
- env: record(schemas_string().regex(ENV_KEY_PATTERN, "Hook env key must be a valid identifier (no prototype-polluting keys)"), schemas_string()).optional()
14373
- }).strict().refine((data)=>Boolean(data.bash) || Boolean(data.powershell), {
14374
- message: "At least one of 'bash' or 'powershell' must be provided and non-empty"
14375
- });
14376
- const hookArray = schemas_array(HookCommandSchema).max(MAX_HOOKS_PER_EVENT);
14377
- const HooksConfigSchema = schemas_object({
14378
- version: literal(1),
14379
- hooks: schemas_object({
14380
- sessionStart: hookArray.optional(),
14381
- userPromptSubmitted: hookArray.optional(),
14382
- preToolUse: hookArray.optional(),
14383
- postToolUse: hookArray.optional(),
14384
- sessionEnd: hookArray.optional()
14385
- }).strict()
14386
- }).strict();
14293
+ ;// CONCATENATED MODULE: ./src/copilot-prompt.ts
14387
14294
 
14388
- ;// CONCATENATED MODULE: ./src/log/query.ts
14389
- // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
14295
+ function formatScriptsSummary(scripts) {
14296
+ return scripts.map((s)=>` - \`${sanitizePromptValue(s.name)}\`: \`${sanitizePromptValue(s.command)}\``).join("\n");
14297
+ }
14298
+ function formatWorkflowSummary(commands) {
14299
+ return commands.map((cmd)=>` - \`${sanitizePromptValue(cmd)}\``).join("\n");
14300
+ }
14301
+ function formatMiseTasksSummary(tasks) {
14302
+ return tasks.map((t)=>` - \`${sanitizePromptValue(t.name)}\`: \`${sanitizePromptValue(t.command)}\``).join("\n");
14303
+ }
14304
+ /**
14305
+ * Builds the system message that establishes persistent behavioral
14306
+ * constraints for the Copilot SDK session. This includes security goals,
14307
+ * tool descriptions, and response format requirements — context that
14308
+ * must be respected across all tool interactions during the session.
14309
+ */ function buildSystemMessage() {
14310
+ return `You are a security-focused policy analyst for agent-shell — a security layer that intercepts terminal commands executed by AI coding agents. Your goal is to generate a minimal, secure allow list for a \`policy.json\` file.
14390
14311
 
14312
+ ## Security Principles
14391
14313
 
14314
+ - Only commands genuinely needed for development workflows should be permitted
14315
+ - Overly broad rules create security risks (e.g. an agent could chain \`npm test && curl evil.com\`)
14316
+ - Use exact commands — avoid wildcards unless the command is genuinely read-only (e.g. \`git status *\`)
14317
+ - Always validate proposed commands with \`validate_allow_rule\` before including them
14318
+ - Commands containing shell metacharacters (\`;\`, \`|\`, \`&\`, \`\`\`, \`$\`, etc.) are never safe
14392
14319
 
14393
- const DURATION_PATTERN = /^(\d+)([mhd])$/;
14394
- const MAX_LINE_BYTES = 65_536;
14395
- const MAX_LINES_PER_FILE = 100_000;
14396
- const UNIT_MS = {
14397
- m: 60 * 1000,
14398
- h: 60 * 60 * 1000,
14399
- d: 24 * 60 * 60 * 1000
14400
- };
14401
- function parseDuration(duration) {
14402
- const match = DURATION_PATTERN.exec(duration);
14403
- if (!match) {
14404
- throw new Error(`Invalid duration format: "${duration}". Expected format: <number><unit> where unit is m, h, or d`);
14405
- }
14406
- const value = Number.parseInt(match[1], 10);
14407
- if (value <= 0) {
14408
- throw new Error(`Duration must be a positive value, got: "${duration}"`);
14409
- }
14410
- const unit = match[2];
14411
- return value * UNIT_MS[unit];
14320
+ ## Available Tools
14321
+
14322
+ ### MCP Tools (lousy-agents server)
14323
+
14324
+ - **discover_feedback_loops**: Discover npm scripts and CLI tools from workflows, mapped to SDLC phases (test, build, lint, format, security). Returns structured results grouped by phase. **Start here.**
14325
+ - **discover_environment**: Discover environment config files (mise.toml, .nvmrc, .python-version, etc.) and detect package managers.
14326
+
14327
+ ### Custom Tools
14328
+
14329
+ - **read_project_file**: Read any file in the repository (truncated at 100KB). Use to inspect configs discovered via MCP tools.
14330
+ - **validate_allow_rule**: Check whether a proposed command is safe for the allow list (rejects commands containing shell metacharacters).
14331
+
14332
+ ## Response Format
14333
+
14334
+ After exploring, respond with **only** a JSON object matching this exact schema — no markdown fences, no explanation:
14335
+
14336
+ \`\`\`json
14337
+ {
14338
+ "additionalAllowRules": ["<command>", ...],
14339
+ "suggestions": ["<human-readable suggestion>", ...]
14412
14340
  }
14413
- async function resolveReadEventsDir(env, deps) {
14414
- const projectRoot = deps.cwd();
14415
- const defaultDir = (0,external_node_path_namespaceObject.join)(projectRoot, ".agent-shell", "events");
14416
- const logDir = env.AGENTSHELL_LOG_DIR;
14417
- if (logDir !== undefined && logDir !== "") {
14418
- const projectRootReal = await deps.realpath(projectRoot);
14419
- const candidate = (0,external_node_path_namespaceObject.resolve)(projectRoot, logDir);
14420
- if (!isWithinProjectRoot(candidate, projectRoot) && !isWithinProjectRoot(candidate, projectRootReal)) {
14421
- return {
14422
- dir: "",
14423
- error: "AGENTSHELL_LOG_DIR resolves outside project root"
14424
- };
14425
- }
14426
- let resolved;
14341
+ \`\`\`
14342
+
14343
+ - \`additionalAllowRules\`: string array of specific commands to add to the allow list
14344
+ - \`suggestions\`: string array of human-readable observations or recommendations about the policy`;
14345
+ }
14346
+ /**
14347
+ * Builds the user prompt containing project-specific scan results
14348
+ * and Socratic questions to guide exploration.
14349
+ */ function buildAnalysisPrompt(scanResult, repoRoot) {
14350
+ const scriptsList = scanResult.scripts.length > 0 ? formatScriptsSummary(scanResult.scripts) : " (none found)";
14351
+ const workflowList = scanResult.workflowCommands.length > 0 ? formatWorkflowSummary(scanResult.workflowCommands) : " (none found)";
14352
+ const miseList = scanResult.miseTasks.length > 0 ? formatMiseTasksSummary(scanResult.miseTasks) : " (none found)";
14353
+ const languagesList = scanResult.languages.length > 0 ? scanResult.languages.join(", ") : "(none detected)";
14354
+ const safeRepoRoot = sanitizePromptValue(repoRoot);
14355
+ return `# Project Analysis Request
14356
+
14357
+ ## Static Analysis Results
14358
+
14359
+ We have already discovered the following from static file analysis:
14360
+
14361
+ - **Repository root**: \`${safeRepoRoot}\`
14362
+ - **Detected languages**: ${languagesList}
14363
+
14364
+ ### npm scripts (from package.json)
14365
+ ${scriptsList}
14366
+
14367
+ ### GitHub Actions workflow commands
14368
+ ${workflowList}
14369
+
14370
+ ### Mise tasks (from mise.toml)
14371
+ ${miseList}
14372
+
14373
+ ## Questions to Explore
14374
+
14375
+ 1. Call \`discover_feedback_loops\` to get a structured view of project commands mapped to SDLC phases. Are there commands the static analysis missed?
14376
+ 2. Call \`discover_environment\` to understand the runtime setup. What toolchain commands does the environment require?
14377
+ 3. Given the detected languages (${languagesList}), what language-specific toolchain commands (e.g. \`cargo test\`, \`go build\`, \`pip install\`) might be needed but aren't captured above?
14378
+ 4. Are there any commands in the discovered lists that look suspicious or overly broad for a development workflow?
14379
+ 5. Use \`validate_allow_rule\` to verify each proposed command before including it in your response.`;
14380
+ }
14381
+
14382
+ ;// CONCATENATED MODULE: external "node:fs"
14383
+ const external_node_fs_namespaceObject = __rspack_createRequire_require("node:fs");
14384
+ ;// CONCATENATED MODULE: external "node:module"
14385
+ const external_node_module_namespaceObject = __rspack_createRequire_require("node:module");
14386
+ ;// CONCATENATED MODULE: external "node:url"
14387
+ const external_node_url_namespaceObject = __rspack_createRequire_require("node:url");
14388
+ ;// CONCATENATED MODULE: ./src/resolve-sdk.ts
14389
+
14390
+
14391
+
14392
+
14393
+ /**
14394
+ * Resolves an npm package's ESM entry point from the user's project directory.
14395
+ *
14396
+ * Bundled CLIs resolve bare specifiers relative to the bundle location, not
14397
+ * the user's working directory. This uses `createRequire` anchored at the
14398
+ * project root to locate the package, then reads its `package.json` exports
14399
+ * map to select the ESM entry that `import()` would use.
14400
+ *
14401
+ * @returns A file:// URL to the package ESM entry point, or null if not found
14402
+ */ function resolveSdkPath(repoRoot, packageName) {
14403
+ if (!repoRoot || !(0,external_node_path_namespaceObject.isAbsolute)(repoRoot)) return null;
14404
+ if (/^[./]/.test(packageName) || packageName.includes("..")) return null;
14405
+ try {
14406
+ const projectRequire = (0,external_node_module_namespaceObject.createRequire)((0,external_node_path_namespaceObject.resolve)(repoRoot, "package.json"));
14407
+ const cjsResolved = projectRequire.resolve(packageName);
14408
+ const esmUrl = findEsmEntry(cjsResolved, packageName);
14409
+ return esmUrl ?? (0,external_node_url_namespaceObject.pathToFileURL)(cjsResolved).href;
14410
+ } catch {
14411
+ return null;
14412
+ }
14413
+ }
14414
+ /**
14415
+ * Walks up from a resolved module path to find the package root directory,
14416
+ * then extracts the ESM entry point from its exports map.
14417
+ */ function findEsmEntry(resolvedPath, packageName) {
14418
+ let dir = (0,external_node_path_namespaceObject.dirname)(resolvedPath);
14419
+ while(true){
14427
14420
  try {
14428
- resolved = await deps.realpath(candidate);
14429
- } catch (err) {
14430
- if (isPathNotFoundError(err)) {
14431
- return {
14432
- dir: "",
14433
- error: "AGENTSHELL_LOG_DIR does not exist or is not a directory"
14434
- };
14421
+ const raw = (0,external_node_fs_namespaceObject.readFileSync)((0,external_node_path_namespaceObject.join)(dir, "package.json"), "utf-8");
14422
+ const pkg = JSON.parse(raw);
14423
+ if (typeof pkg === "object" && pkg !== null && pkg.name === packageName) {
14424
+ const esmEntry = extractEsmEntry(pkg);
14425
+ const full = (0,external_node_path_namespaceObject.resolve)(dir, esmEntry);
14426
+ try {
14427
+ return (0,external_node_url_namespaceObject.pathToFileURL)((0,external_node_fs_namespaceObject.realpathSync)(full)).href;
14428
+ } catch {
14429
+ return null;
14430
+ }
14435
14431
  }
14436
- throw err;
14437
- }
14438
- if (!isWithinProjectRoot(resolved, projectRootReal)) {
14439
- return {
14440
- dir: "",
14441
- error: "AGENTSHELL_LOG_DIR resolves outside project root"
14442
- };
14443
- }
14444
- return {
14445
- dir: resolved
14446
- };
14432
+ } catch {
14433
+ /* no package.json at this level */ }
14434
+ const parent = (0,external_node_path_namespaceObject.dirname)(dir);
14435
+ if (parent === dir) return null;
14436
+ dir = parent;
14447
14437
  }
14448
- return {
14449
- dir: defaultDir
14450
- };
14451
14438
  }
14452
- function findMostRecentFile(fileMtimes) {
14453
- let mostRecent = fileMtimes[0];
14454
- for(let i = 1; i < fileMtimes.length; i++){
14455
- if (fileMtimes[i].mtimeMs > mostRecent.mtimeMs) {
14456
- mostRecent = fileMtimes[i];
14457
- }
14439
+ /**
14440
+ * Extracts the ESM entry point from a parsed package.json object.
14441
+ */ function extractEsmEntry(pkgJson) {
14442
+ if (typeof pkgJson === "object" && pkgJson !== null) {
14443
+ const pkg = pkgJson;
14444
+ const esmPath = resolveExportsImportEntry(pkg.exports);
14445
+ if (esmPath) return esmPath;
14446
+ if (typeof pkg.main === "string" && pkg.main.length > 0) return pkg.main;
14458
14447
  }
14459
- return mostRecent.file;
14448
+ return "index.js";
14460
14449
  }
14461
- function matchesFilters(event, filters, cutoffMs) {
14462
- if (filters.actor !== undefined && event.actor !== filters.actor) {
14463
- return false;
14464
- }
14465
- if (filters.failures && !(event.event === "script_end" && event.exit_code !== 0)) {
14466
- return false;
14467
- }
14468
- if (filters.script !== undefined && !(event.event === "script_end" && event.script === filters.script)) {
14469
- return false;
14450
+ /**
14451
+ * Navigates the exports map to find the ESM entry. Supports string-shaped
14452
+ * exports, string-valued `exports["."]`, `exports["."].import` (string),
14453
+ * `exports["."].import.default`, and the sugar form where condition keys
14454
+ * (`import`/`require`) appear directly on the exports object.
14455
+ */ function resolveExportsImportEntry(exports) {
14456
+ if (typeof exports === "string") return exports;
14457
+ if (typeof exports !== "object" || exports === null) return null;
14458
+ const exportsMap = exports;
14459
+ const dotEntry = exportsMap["."];
14460
+ if (typeof dotEntry === "string") return dotEntry;
14461
+ const conditionSource = typeof dotEntry === "object" && dotEntry !== null && !Array.isArray(dotEntry) ? dotEntry : exportsMap;
14462
+ const importEntry = conditionSource.import;
14463
+ if (typeof importEntry === "string") return importEntry;
14464
+ if (typeof importEntry === "object" && importEntry !== null) {
14465
+ const importMap = importEntry;
14466
+ if (typeof importMap.default === "string") return importMap.default;
14470
14467
  }
14471
- if (cutoffMs !== undefined) {
14472
- const eventMs = new Date(event.timestamp).getTime();
14473
- if (eventMs < cutoffMs) {
14474
- return false;
14475
- }
14468
+ return null;
14469
+ }
14470
+
14471
+ ;// CONCATENATED MODULE: ./src/copilot-enhance.ts
14472
+
14473
+
14474
+
14475
+
14476
+
14477
+
14478
+ const DEFAULT_MODEL = "claude-sonnet-4-6";
14479
+ const MAX_FILE_READ_BYTES = 102_400;
14480
+ const AnalysisResponseSchema = schemas_object({
14481
+ additionalAllowRules: schemas_array(schemas_string().max(512)).max(100),
14482
+ suggestions: schemas_array(schemas_string().max(1024)).max(100)
14483
+ });
14484
+ /**
14485
+ * Resolves a relative path against a root directory and verifies it
14486
+ * does not escape the root via traversal (e.g. `../../etc/passwd`).
14487
+ *
14488
+ * @returns The resolved absolute path, or null if it escapes the root
14489
+ */ function resolveSafePath(repoRoot, relativePath) {
14490
+ const root = repoRoot.replace(/\/+$/, "") || "/";
14491
+ const resolved = (0,external_node_path_namespaceObject.resolve)(root, (0,external_node_path_namespaceObject.normalize)(relativePath));
14492
+ const prefix = root === "/" ? "/" : `${root}/`;
14493
+ if (!resolved.startsWith(prefix) && resolved !== root) {
14494
+ return null;
14476
14495
  }
14477
- return true;
14496
+ return resolved;
14478
14497
  }
14479
- function parseLine(line) {
14480
- if (Buffer.byteLength(line, "utf-8") > MAX_LINE_BYTES) {
14481
- return undefined;
14498
+ /**
14499
+ * Finds the largest byte offset ≤ maxBytes that falls on a clean UTF-8
14500
+ * codepoint boundary within the given buffer.
14501
+ *
14502
+ * Prevents splitting multi-byte sequences when truncating raw buffers.
14503
+ */ function findUtf8Boundary(buf, maxBytes) {
14504
+ if (maxBytes >= buf.length) return buf.length;
14505
+ if (maxBytes === 0) return 0;
14506
+ // Check the byte at the cut point (first excluded byte).
14507
+ // If it's not a continuation byte (10xxxxxx), maxBytes is already a
14508
+ // clean boundary — the previous character ended before this position.
14509
+ if ((buf[maxBytes] & 0xc0) !== 0x80) {
14510
+ return maxBytes;
14511
+ }
14512
+ // The first excluded byte is a continuation byte, so we're splitting
14513
+ // a multi-byte character. Walk backwards to find the start byte.
14514
+ let boundary = maxBytes;
14515
+ while(boundary > 0 && (buf[boundary - 1] & 0xc0) === 0x80){
14516
+ boundary--;
14517
+ }
14518
+ // boundary-1 is now the start byte of the incomplete character.
14519
+ // Exclude it since its full sequence extends past maxBytes.
14520
+ if (boundary > 0) {
14521
+ boundary--;
14522
+ }
14523
+ return boundary;
14524
+ }
14525
+ /**
14526
+ * Reads a project file safely, enforcing path traversal protection
14527
+ * and byte-level truncation. Extracted for testability.
14528
+ */ async function readProjectFileSafe(repoRoot, pathArg) {
14529
+ if (pathArg.length === 0) {
14530
+ return {
14531
+ error: "Path is required"
14532
+ };
14482
14533
  }
14483
- let parsed;
14534
+ const safePath = resolveSafePath(repoRoot, pathArg);
14535
+ if (safePath === null) {
14536
+ return {
14537
+ error: "Path is outside the repository"
14538
+ };
14539
+ }
14540
+ let fileBuffer;
14484
14541
  try {
14485
- parsed = JSON.parse(line);
14542
+ const root = repoRoot.replace(/\/+$/, "") || "/";
14543
+ const [realRoot, realPath] = await Promise.all([
14544
+ (0,promises_namespaceObject.realpath)(root),
14545
+ (0,promises_namespaceObject.realpath)(safePath)
14546
+ ]);
14547
+ const realPrefix = realRoot === "/" ? "/" : `${realRoot}/`;
14548
+ if (!realPath.startsWith(realPrefix) && realPath !== realRoot) {
14549
+ return {
14550
+ error: "Path is outside the repository"
14551
+ };
14552
+ }
14553
+ fileBuffer = await (0,promises_namespaceObject.readFile)(realPath);
14486
14554
  } catch {
14487
- return undefined;
14488
- }
14489
- const result = ScriptEventSchema.safeParse(parsed);
14490
- if (!result.success) {
14491
- return undefined;
14555
+ return {
14556
+ error: "File not found or unreadable"
14557
+ };
14492
14558
  }
14493
- return result.data;
14494
- }
14495
- async function queryEvents(eventsDir, filters, deps) {
14496
- const allFiles = await deps.readdir(eventsDir);
14497
- const jsonlFiles = allFiles.filter((f)=>f.endsWith(".jsonl"));
14498
- if (jsonlFiles.length === 0) {
14559
+ // Buffer processing outside I/O catch — programming bugs in
14560
+ // findUtf8Boundary propagate instead of being silently swallowed.
14561
+ if (fileBuffer.length <= MAX_FILE_READ_BYTES) {
14499
14562
  return {
14500
- events: [],
14501
- truncatedFiles: []
14563
+ content: fileBuffer.toString("utf-8"),
14564
+ truncated: false
14502
14565
  };
14503
14566
  }
14504
- let filesToRead;
14505
- if (filters.last === undefined) {
14506
- const fileMtimes = await Promise.all(jsonlFiles.map(async (file)=>{
14507
- const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, file);
14508
- const s = await deps.stat(filePath);
14509
- return {
14510
- file,
14511
- mtimeMs: s.mtimeMs
14512
- };
14513
- }));
14514
- filesToRead = [
14515
- findMostRecentFile(fileMtimes)
14516
- ];
14517
- } else {
14518
- filesToRead = jsonlFiles;
14519
- }
14520
- const cutoffMs = filters.last ? Date.now() - parseDuration(filters.last) : undefined;
14521
- const events = [];
14522
- const truncatedFiles = [];
14523
- for (const file of filesToRead){
14524
- const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, file);
14525
- let lineCount = 0;
14526
- for await (const line of deps.readFileLines(filePath)){
14527
- if (lineCount >= MAX_LINES_PER_FILE) {
14528
- deps.writeStderr(`agent-shell: file ${file} exceeds ${MAX_LINES_PER_FILE} lines, truncating\n`);
14529
- truncatedFiles.push(file);
14530
- break;
14531
- }
14532
- lineCount++;
14533
- const event = parseLine(line);
14534
- if (event === undefined) {
14535
- continue;
14567
+ const boundary = findUtf8Boundary(fileBuffer, MAX_FILE_READ_BYTES);
14568
+ return {
14569
+ content: fileBuffer.subarray(0, boundary).toString("utf-8"),
14570
+ truncated: true
14571
+ };
14572
+ }
14573
+ /**
14574
+ * Creates custom tools that allow the Copilot model to read files
14575
+ * and validate proposed allow rules. Structured project discovery
14576
+ * is handled by the lousy-agents MCP server (connected via mcpServers).
14577
+ */ function createCustomTools(repoRoot, defineTool) {
14578
+ const readProjectFile = defineTool("read_project_file", {
14579
+ description: "Read a file from the project repository. Returns file content (truncated at 100KB). Use to inspect build configs, Dockerfiles, Makefiles, etc.",
14580
+ parameters: {
14581
+ type: "object",
14582
+ properties: {
14583
+ path: {
14584
+ type: "string",
14585
+ description: "Relative path from repository root"
14586
+ }
14587
+ },
14588
+ required: [
14589
+ "path"
14590
+ ]
14591
+ },
14592
+ skipPermission: true,
14593
+ handler: (args)=>readProjectFileSafe(repoRoot, args.path ?? "")
14594
+ });
14595
+ const validateAllowRule = defineTool("validate_allow_rule", {
14596
+ description: "Check whether a proposed allow rule is safe (does not contain shell metacharacters like ;, |, &, `, $, etc.).",
14597
+ parameters: {
14598
+ type: "object",
14599
+ properties: {
14600
+ command: {
14601
+ type: "string",
14602
+ description: "The command to validate as a policy rule"
14603
+ }
14604
+ },
14605
+ required: [
14606
+ "command"
14607
+ ]
14608
+ },
14609
+ skipPermission: true,
14610
+ handler: async (args)=>{
14611
+ const command = args.command ?? "";
14612
+ if (isSafeCommand(command)) {
14613
+ return {
14614
+ safe: true,
14615
+ reason: "No shell metacharacters detected"
14616
+ };
14536
14617
  }
14537
- if (matchesFilters(event, filters, cutoffMs)) {
14538
- events.push(event);
14618
+ const normalized = command.trim();
14619
+ if (normalized.length === 0) {
14620
+ return {
14621
+ safe: false,
14622
+ reason: "Empty or whitespace-only commands are not allowed in policy rules"
14623
+ };
14539
14624
  }
14625
+ return {
14626
+ safe: false,
14627
+ reason: "Contains shell metacharacters — compound commands are not allowed in policy rules"
14628
+ };
14540
14629
  }
14541
- }
14542
- return {
14543
- events,
14544
- truncatedFiles
14545
- };
14630
+ });
14631
+ return [
14632
+ readProjectFile,
14633
+ validateAllowRule
14634
+ ];
14546
14635
  }
14547
- async function listSessions(eventsDir, deps) {
14548
- const allFiles = await deps.readdir(eventsDir);
14549
- const jsonlFiles = allFiles.filter((f)=>f.endsWith(".jsonl"));
14550
- if (jsonlFiles.length === 0) {
14551
- return [];
14552
- }
14553
- const summaries = [];
14554
- for (const file of jsonlFiles){
14555
- const sessionId = file.replace(/\.jsonl$/, "");
14556
- const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, file);
14557
- let firstEvent;
14558
- let lastEvent;
14559
- let eventCount = 0;
14560
- const actorSet = new Set();
14561
- for await (const line of deps.readFileLines(filePath)){
14562
- let parsed;
14636
+ /**
14637
+ * Attempts to use the @github/copilot-sdk to enhance policy generation
14638
+ * with AI-powered project analysis. Connects to the lousy-agents MCP
14639
+ * server for structured project discovery (feedback loops, environment)
14640
+ * and provides custom tools for file reading and rule validation.
14641
+ * Falls back gracefully if the SDK or Copilot CLI is not available.
14642
+ *
14643
+ * @returns Enhanced analysis results, or null if the SDK is unavailable
14644
+ */ async function enhanceWithCopilot(scanResult, repoRoot, writeStderr, model = DEFAULT_MODEL) {
14645
+ let importSucceeded = false;
14646
+ try {
14647
+ const sdkPath = resolveSdkPath(repoRoot, "@github/copilot-sdk");
14648
+ const { CopilotClient, defineTool, approveAll } = sdkPath ? await import(/* webpackIgnore: true */ sdkPath) : await __webpack_require__.e(/* import() */ "300").then(__webpack_require__.bind(__webpack_require__, 791));
14649
+ importSucceeded = true;
14650
+ const client = new CopilotClient();
14651
+ const tools = createCustomTools(repoRoot, defineTool);
14652
+ try {
14653
+ await client.start();
14654
+ const session = await client.createSession({
14655
+ model,
14656
+ tools,
14657
+ onPermissionRequest: approveAll,
14658
+ systemMessage: {
14659
+ content: buildSystemMessage()
14660
+ },
14661
+ mcpServers: {
14662
+ "lousy-agents": {
14663
+ type: "local",
14664
+ command: "npx",
14665
+ args: [
14666
+ "-y",
14667
+ "@lousy-agents/mcp"
14668
+ ],
14669
+ cwd: repoRoot,
14670
+ tools: [
14671
+ "discover_feedback_loops",
14672
+ "discover_environment"
14673
+ ]
14674
+ }
14675
+ }
14676
+ });
14563
14677
  try {
14564
- parsed = JSON.parse(line);
14565
- } catch {
14566
- continue;
14567
- }
14568
- const result = ScriptEventSchema.safeParse(parsed);
14569
- if (!result.success) {
14570
- continue;
14678
+ const prompt = buildAnalysisPrompt(scanResult, repoRoot);
14679
+ const response = await session.sendAndWait({
14680
+ prompt
14681
+ });
14682
+ const data = typeof response?.data === "object" && response.data !== null ? response.data : undefined;
14683
+ const content = data !== undefined && "content" in data && typeof data.content === "string" ? data.content : "";
14684
+ return parseAnalysisResponse(content);
14685
+ } finally{
14686
+ await session.disconnect();
14571
14687
  }
14572
- const event = result.data;
14573
- eventCount++;
14574
- actorSet.add(event.actor);
14575
- if (firstEvent === undefined || event.timestamp < firstEvent) {
14576
- firstEvent = event.timestamp;
14688
+ } finally{
14689
+ await client.stop();
14690
+ }
14691
+ } catch (err) {
14692
+ if (process.env.AGENT_SHELL_COPILOT_DEBUG) {
14693
+ if (importSucceeded) {
14694
+ writeStderr(`agent-shell: Copilot analysis failed — ${sanitizeForStderr(err)}\n`);
14695
+ } else {
14696
+ writeStderr("agent-shell: Copilot SDK not available — using static analysis only\n");
14577
14697
  }
14578
- if (lastEvent === undefined || event.timestamp > lastEvent) {
14579
- lastEvent = event.timestamp;
14698
+ }
14699
+ return null;
14700
+ }
14701
+ }
14702
+ /**
14703
+ * Finds the first well-formed JSON object in `content` using brace-balancing,
14704
+ * rather than a greedy regex that could capture extra trailing braces and
14705
+ * fail JSON.parse on valid responses that include preamble or postamble text.
14706
+ */ function extractFirstJsonObject(content) {
14707
+ const start = content.indexOf("{");
14708
+ if (start === -1) {
14709
+ return null;
14710
+ }
14711
+ let inString = false;
14712
+ let escaping = false;
14713
+ let depth = 0;
14714
+ for(let i = start; i < content.length; i += 1){
14715
+ const ch = content[i];
14716
+ if (escaping) {
14717
+ escaping = false;
14718
+ continue;
14719
+ }
14720
+ if (ch === "\\") {
14721
+ if (inString) {
14722
+ escaping = true;
14580
14723
  }
14724
+ continue;
14581
14725
  }
14582
- if (eventCount > 0 && firstEvent !== undefined && lastEvent !== undefined) {
14583
- summaries.push({
14584
- sessionId,
14585
- firstEvent,
14586
- lastEvent,
14587
- eventCount,
14588
- actors: [
14589
- ...actorSet
14590
- ]
14591
- });
14726
+ if (ch === '"') {
14727
+ inString = !inString;
14728
+ continue;
14729
+ }
14730
+ if (!inString) {
14731
+ if (ch === "{") {
14732
+ depth += 1;
14733
+ } else if (ch === "}") {
14734
+ depth -= 1;
14735
+ if (depth === 0) {
14736
+ return content.slice(start, i + 1);
14737
+ }
14738
+ }
14592
14739
  }
14593
14740
  }
14594
- summaries.sort((a, b)=>b.lastEvent.localeCompare(a.lastEvent));
14595
- return summaries;
14741
+ return null;
14742
+ }
14743
+ function parseAnalysisResponse(content) {
14744
+ const jsonText = extractFirstJsonObject(content);
14745
+ if (!jsonText) {
14746
+ return null;
14747
+ }
14748
+ try {
14749
+ const parsed = JSON.parse(jsonText);
14750
+ return AnalysisResponseSchema.parse(parsed);
14751
+ } catch {
14752
+ return null;
14753
+ }
14596
14754
  }
14597
14755
 
14598
- ;// CONCATENATED MODULE: ./src/log/index.ts
14599
-
14600
-
14601
-
14756
+ ;// CONCATENATED MODULE: ./src/project-scanner.ts
14602
14757
 
14603
14758
 
14604
- function parseLogArgs(args) {
14605
- const options = {
14606
- failures: false,
14607
- listSessions: false,
14608
- json: false
14609
- };
14610
- let i = 0;
14611
- while(i < args.length){
14612
- const arg = args[i];
14613
- switch(arg){
14614
- case "--last":
14615
- if (i + 1 < args.length) {
14616
- options.last = args[++i];
14617
- } else {
14618
- options.errors = options.errors ?? [];
14619
- options.errors.push("--last requires a value (e.g., 30m, 1h, 1d)");
14620
- }
14621
- break;
14622
- case "--actor":
14623
- if (i + 1 < args.length) {
14624
- options.actor = args[++i];
14625
- } else {
14626
- options.errors = options.errors ?? [];
14627
- options.errors.push("--actor requires a value");
14628
- }
14629
- break;
14630
- case "--failures":
14631
- options.failures = true;
14632
- break;
14633
- case "--script":
14634
- if (i + 1 < args.length) {
14635
- options.script = args[++i];
14636
- } else {
14637
- options.errors = options.errors ?? [];
14638
- options.errors.push("--script requires a value");
14639
- }
14640
- break;
14641
- case "--list-sessions":
14642
- options.listSessions = true;
14643
- break;
14644
- case "--json":
14645
- options.json = true;
14646
- break;
14647
- }
14648
- i++;
14759
+ const MAX_WORKFLOW_FILES = 100;
14760
+ const MAX_FILE_SIZE_BYTES = 524_288;
14761
+ const LANGUAGE_MARKERS = {
14762
+ "package.json": "node",
14763
+ "requirements.txt": "python",
14764
+ // biome-ignore lint/style/useNamingConvention: filename on disk
14765
+ Pipfile: "python",
14766
+ "pyproject.toml": "python",
14767
+ "setup.py": "python",
14768
+ "go.mod": "go",
14769
+ "Cargo.toml": "rust",
14770
+ // biome-ignore lint/style/useNamingConvention: filename on disk
14771
+ Gemfile: "ruby",
14772
+ "pom.xml": "java",
14773
+ "build.gradle": "java",
14774
+ "build.gradle.kts": "java"
14775
+ };
14776
+ async function fileExists(path) {
14777
+ try {
14778
+ const s = await (0,promises_namespaceObject.stat)(path);
14779
+ return s.isFile();
14780
+ } catch {
14781
+ return false;
14649
14782
  }
14650
- return options;
14651
14783
  }
14652
- function createDefaultQueryDeps() {
14653
- return {
14654
- readdir: (path)=>(0,promises_namespaceObject.readdir)(path),
14655
- stat: (path)=>(0,promises_namespaceObject.stat)(path).then((s)=>({
14656
- mtimeMs: s.mtimeMs
14657
- })),
14658
- realpath: (path)=>(0,promises_namespaceObject.realpath)(path),
14659
- cwd: ()=>process.cwd(),
14660
- readFileLines: (path)=>(0,external_node_readline_namespaceObject.createInterface)({
14661
- input: (0,external_node_fs_namespaceObject.createReadStream)(path, {
14662
- encoding: "utf-8"
14663
- })
14664
- }),
14665
- writeStderr: (msg)=>{
14666
- process.stderr.write(msg);
14667
- }
14668
- };
14669
- }
14670
- async function runLog(args) {
14671
- const options = parseLogArgs(args);
14672
- const deps = createDefaultQueryDeps();
14673
- if (options.errors && options.errors.length > 0) {
14674
- for (const err of options.errors){
14675
- process.stderr.write(`agent-shell: ${err}\n`);
14676
- }
14677
- return 1;
14678
- }
14679
- const { dir, error } = await resolveReadEventsDir(process.env, deps);
14680
- if (error) {
14681
- process.stderr.write(`agent-shell: ${error}\n`);
14682
- return 1;
14683
- }
14684
- if (options.last) {
14685
- try {
14686
- parseDuration(options.last);
14687
- } catch (err) {
14688
- process.stderr.write(`agent-shell: ${err.message}\n`);
14689
- return 1;
14690
- }
14691
- }
14784
+ async function dirExists(path) {
14692
14785
  try {
14693
- await deps.readdir(dir);
14786
+ const s = await (0,promises_namespaceObject.stat)(path);
14787
+ return s.isDirectory();
14694
14788
  } catch {
14695
- process.stdout.write("No events recorded yet.\n\n");
14696
- process.stdout.write("To enable instrumentation, add to your .npmrc:\n");
14697
- process.stdout.write(" script-shell=./node_modules/.bin/agent-shell\n");
14698
- return 0;
14699
- }
14700
- if (options.listSessions) {
14701
- const sessions = await listSessions(dir, deps);
14702
- if (sessions.length === 0) {
14703
- process.stdout.write("No sessions found.\n");
14704
- return 0;
14705
- }
14706
- process.stdout.write(formatSessionsTable(sessions));
14707
- process.stdout.write("\n");
14708
- return 0;
14709
- }
14710
- const result = await queryEvents(dir, {
14711
- actor: options.actor,
14712
- failures: options.failures || undefined,
14713
- script: options.script,
14714
- last: options.last
14715
- }, deps);
14716
- if (result.events.length === 0) {
14717
- process.stdout.write("No matching events found.\n");
14718
- return 0;
14719
- }
14720
- if (options.json) {
14721
- process.stdout.write(formatEventsJson(result.events));
14722
- process.stdout.write("\n");
14723
- } else {
14724
- process.stdout.write(formatEventsTable(result.events));
14725
- process.stdout.write("\n");
14789
+ return false;
14726
14790
  }
14727
- return 0;
14728
14791
  }
14729
-
14730
- ;// CONCATENATED MODULE: ./src/mode.ts
14731
- const MODEL_PATTERN = /^[a-zA-Z0-9._-]+$/;
14732
- function parsePolicyInitOptions(args) {
14733
- const options = {};
14734
- for (const arg of args.slice(2)){
14735
- if (arg.startsWith("--model=")) {
14736
- const value = arg.slice("--model=".length);
14737
- if (value.length > 0 && value.length <= 128 && MODEL_PATTERN.test(value)) {
14738
- options.model = value;
14792
+ async function discoverScripts(targetDir) {
14793
+ const packageJsonPath = (0,external_node_path_namespaceObject.join)(targetDir, "package.json");
14794
+ if (!await fileExists(packageJsonPath)) {
14795
+ return [];
14796
+ }
14797
+ try {
14798
+ const fileStat = await (0,promises_namespaceObject.stat)(packageJsonPath);
14799
+ if (fileStat.size > MAX_FILE_SIZE_BYTES) {
14800
+ return [];
14801
+ }
14802
+ const content = await (0,promises_namespaceObject.readFile)(packageJsonPath, "utf-8");
14803
+ const parsed = JSON.parse(content);
14804
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
14805
+ return [];
14806
+ }
14807
+ const pkg = parsed;
14808
+ const scripts = pkg.scripts;
14809
+ if (scripts === null || scripts === undefined || typeof scripts !== "object" || Array.isArray(scripts)) {
14810
+ return [];
14811
+ }
14812
+ const result = [];
14813
+ for (const [name, command] of Object.entries(scripts)){
14814
+ if (typeof command === "string") {
14815
+ result.push({
14816
+ name,
14817
+ command
14818
+ });
14739
14819
  }
14740
14820
  }
14821
+ return result;
14822
+ } catch {
14823
+ return [];
14741
14824
  }
14742
- return options;
14743
14825
  }
14744
- function resolveMode(args, env) {
14745
- const firstArg = args[0];
14746
- if (firstArg === "policy-check") return {
14747
- type: "policy-check"
14748
- };
14749
- if (firstArg === "policy" && args[1] === "--init") {
14750
- const options = parsePolicyInitOptions(args);
14751
- return {
14752
- type: "policy-init",
14753
- model: options.model
14754
- };
14826
+ async function discoverWorkflowCommands(targetDir) {
14827
+ const workflowsDir = (0,external_node_path_namespaceObject.join)(targetDir, ".github", "workflows");
14828
+ if (!await dirExists(workflowsDir)) {
14829
+ return [];
14755
14830
  }
14756
- if (env.AGENTSHELL_PASSTHROUGH === "1") {
14757
- return {
14758
- type: "passthrough",
14759
- args
14760
- };
14831
+ let files;
14832
+ try {
14833
+ files = await (0,promises_namespaceObject.readdir)(workflowsDir);
14834
+ } catch {
14835
+ return [];
14761
14836
  }
14762
- if (firstArg === "--version") return {
14763
- type: "version"
14764
- };
14765
- if (firstArg === "-c" && args[1]) return {
14766
- type: "shim",
14767
- command: args[1]
14768
- };
14769
- if (firstArg === "log") return {
14770
- type: "log"
14771
- };
14772
- return {
14773
- type: "usage"
14774
- };
14775
- }
14776
-
14777
- ;// CONCATENATED MODULE: ./src/sanitize.ts
14778
- /**
14779
- * Escapes ASCII and C1 control characters in error messages before writing
14780
- * to stderr. Replaces each control character with its `\xNN` hex
14781
- * representation to prevent log/terminal injection when errors embed
14782
- * untrusted data.
14783
- */ function sanitizeForStderr(err) {
14784
- const msg = err instanceof Error ? err.message : String(err);
14785
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
14786
- return msg.replace(/[\u0000-\u001f\u007f-\u009f]/g, (ch)=>{
14787
- return `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
14788
- });
14837
+ const yamlFiles = files.filter((f)=>f.endsWith(".yml") || f.endsWith(".yaml")).slice(0, MAX_WORKFLOW_FILES);
14838
+ const allCommands = [];
14839
+ for (const file of yamlFiles){
14840
+ try {
14841
+ const filePath = (0,external_node_path_namespaceObject.join)(workflowsDir, file);
14842
+ const fileStat = await (0,promises_namespaceObject.stat)(filePath);
14843
+ if (fileStat.size > MAX_FILE_SIZE_BYTES) {
14844
+ continue;
14845
+ }
14846
+ const content = await (0,promises_namespaceObject.readFile)(filePath, "utf-8");
14847
+ const commands = extractRunCommandsFromYaml(content);
14848
+ allCommands.push(...commands);
14849
+ } catch {}
14850
+ }
14851
+ return [
14852
+ ...new Set(allCommands)
14853
+ ].filter(isUsefulCommand);
14789
14854
  }
14790
14855
  /**
14791
- * Escapes ASCII and C1 control characters (except newline) in output text.
14792
- * Like sanitizeForStderr but preserves newlines for JSON formatting.
14793
- */ function sanitizeOutput(text) {
14794
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
14795
- return text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, (ch)=>{
14796
- return `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
14797
- });
14798
- }
14799
- /**
14800
- * Sanitizes untrusted values before embedding in prompts.
14801
- * Strips newlines and all backticks that could inject instructions,
14802
- * and truncates to a safe length.
14803
- */ function sanitizePromptValue(value) {
14804
- return value.replace(/[\n\r]/g, " ").replace(/`/g, "").slice(0, 256);
14805
- }
14806
- /**
14807
- * Shell metacharacters that indicate compound or piped commands.
14808
- * Commands containing these are excluded from the allow list because
14809
- * they could mask injection (e.g. `npm test && curl evil`).
14810
- */ const SHELL_METACHAR_PATTERN = /[;|&`><$()\\\n\r]/;
14811
- /**
14812
- * Returns true if the command is non-empty, contains no shell metacharacters,
14813
- * and is safe to include in a policy allow list.
14814
- */ function isSafeCommand(command) {
14815
- const normalized = command.trim();
14816
- if (normalized.length === 0) {
14817
- return false;
14818
- }
14819
- return !SHELL_METACHAR_PATTERN.test(normalized);
14856
+ * Patterns that match shell control structures, variable assignments,
14857
+ * and other non-command lines extracted from multi-line workflow `run:`
14858
+ * blocks. These are filtered out because they are not meaningful tool
14859
+ * invocations that should appear in a policy allow list — only actual
14860
+ * commands (npm, mise, node, git, etc.) are useful for policy rules.
14861
+ */ const SHELL_NOISE_PATTERNS = [
14862
+ /^(if|then|else|elif|fi|for|while|do|done|case|esac)\b/,
14863
+ /^(set\s+[+-]e|set\s+[+-]o)/,
14864
+ /^(exit\s+\d|exit\s+\$)/,
14865
+ /^\w+="?\$\{\{/,
14866
+ /^[A-Z_]+=("[^"]*"|'[^']*'|\S+)\s*\\?$/,
14867
+ /^(echo|printf)\s/,
14868
+ /^(cd|mkdir|rm|test)\s/,
14869
+ /^>\s/,
14870
+ /^(else|fi|done|esac)$/,
14871
+ /^\w+=\$\?$/,
14872
+ /^\\$/,
14873
+ /^'[^']*'\s*\\?$/,
14874
+ /^"[^"]*"\s*\\?$/,
14875
+ /^node\s+"?\$/
14876
+ ];
14877
+ function isUsefulCommand(cmd) {
14878
+ if (cmd.length < 3) return false;
14879
+ if (cmd.endsWith(")") && !cmd.includes("(")) return false;
14880
+ return !SHELL_NOISE_PATTERNS.some((pattern)=>pattern.test(cmd));
14820
14881
  }
14821
-
14822
- ;// CONCATENATED MODULE: ./src/policy.ts
14823
-
14824
-
14825
-
14826
-
14827
- const DEFAULT_POLICY_SUBPATH = ".github/hooks/agent-shell/policy.json";
14828
14882
  /**
14829
- * Glob matcher supporting only `*` wildcards.
14830
- *
14831
- * Splits the rule on `*` into literal segments and checks that each
14832
- * segment appears in the command in order. O(n·m) worst case where
14833
- * n = command length and m = rule length, with no exponential
14834
- * backtracking (unlike regex `.*` quantifiers).
14835
- */ function matchesRule(command, rule) {
14836
- const segments = rule.split("*");
14837
- if (segments.length === 1) {
14838
- return command === rule;
14839
- }
14840
- const prefixSegment = segments[0];
14841
- const suffixSegment = segments[segments.length - 1];
14842
- const innerSegments = segments.slice(1, -1);
14843
- if (!command.startsWith(prefixSegment)) {
14844
- return false;
14845
- }
14846
- let cursor = prefixSegment.length;
14847
- for (const segment of innerSegments){
14848
- const index = command.indexOf(segment, cursor);
14849
- if (index === -1) {
14850
- return false;
14883
+ * Simple extraction of `run:` values from YAML content.
14884
+ * Uses line-based parsing rather than a full YAML parser to avoid
14885
+ * adding yaml as a dependency to agent-shell.
14886
+ */ function extractRunCommandsFromYaml(content) {
14887
+ const commands = [];
14888
+ const lines = content.split("\n");
14889
+ let inRunBlock = false;
14890
+ let runIndent = 0;
14891
+ let isFoldedBlock = false;
14892
+ let foldedLines = [];
14893
+ let continuationBuffer = "";
14894
+ function flushFoldedBlock() {
14895
+ if (foldedLines.length > 0) {
14896
+ commands.push(foldedLines.join(" "));
14897
+ foldedLines = [];
14851
14898
  }
14852
- cursor = index + segment.length;
14853
- }
14854
- const suffixStart = command.length - suffixSegment.length;
14855
- return suffixStart >= cursor && command.endsWith(suffixSegment);
14856
- }
14857
- function evaluatePolicy(policy, command) {
14858
- if (policy === null) {
14859
- return {
14860
- decision: "allow",
14861
- matchedRule: null
14862
- };
14863
14899
  }
14864
- const trimmed = command.trim();
14865
- if (SHELL_METACHAR_PATTERN.test(trimmed)) {
14866
- return {
14867
- decision: "deny",
14868
- matchedRule: null
14869
- };
14870
- }
14871
- for (const rule of policy.deny){
14872
- if (matchesRule(trimmed, rule)) {
14873
- return {
14874
- decision: "deny",
14875
- matchedRule: rule
14876
- };
14900
+ function flushContinuation() {
14901
+ if (continuationBuffer.length > 0) {
14902
+ commands.push(continuationBuffer);
14903
+ continuationBuffer = "";
14877
14904
  }
14878
14905
  }
14879
- if (policy.allow !== undefined) {
14880
- const matchesAny = policy.allow.some((rule)=>matchesRule(trimmed, rule));
14881
- if (!matchesAny) {
14882
- return {
14883
- decision: "deny",
14884
- matchedRule: null
14885
- };
14906
+ for(let i = 0; i < lines.length; i++){
14907
+ const line = lines[i];
14908
+ if (line === undefined) continue;
14909
+ const trimmed = line.trimStart();
14910
+ const indent = line.length - trimmed.length;
14911
+ if (inRunBlock) {
14912
+ if (trimmed.length === 0) {
14913
+ continue;
14914
+ }
14915
+ if (indent > runIndent) {
14916
+ let cmd = trimmed.trimEnd();
14917
+ cmd = cmd.replace(/\s+#.*$/, "").trimEnd();
14918
+ const isContinuation = cmd.endsWith("\\");
14919
+ if (isContinuation) {
14920
+ cmd = cmd.slice(0, -1).trimEnd();
14921
+ }
14922
+ if (cmd.length > 0 && !cmd.startsWith("#")) {
14923
+ if (isFoldedBlock) {
14924
+ foldedLines.push(cmd);
14925
+ } else if (isContinuation) {
14926
+ continuationBuffer = continuationBuffer.length > 0 ? `${continuationBuffer} ${cmd}` : cmd;
14927
+ } else if (continuationBuffer.length > 0) {
14928
+ commands.push(`${continuationBuffer} ${cmd}`);
14929
+ continuationBuffer = "";
14930
+ } else {
14931
+ commands.push(cmd);
14932
+ }
14933
+ }
14934
+ } else {
14935
+ flushContinuation();
14936
+ if (isFoldedBlock) {
14937
+ flushFoldedBlock();
14938
+ }
14939
+ inRunBlock = false;
14940
+ isFoldedBlock = false;
14941
+ }
14942
+ }
14943
+ if (!inRunBlock) {
14944
+ const runMatch = trimmed.match(/^-?\s*run:\s*(.*)$/);
14945
+ if (runMatch) {
14946
+ const value = runMatch[1]?.trim();
14947
+ const firstToken = value ? value.split(/[ \t#]/, 1)[0] ?? "" : "";
14948
+ if (/^[|>][-+]?$/.test(firstToken)) {
14949
+ inRunBlock = true;
14950
+ runIndent = indent;
14951
+ isFoldedBlock = firstToken.startsWith(">");
14952
+ foldedLines = [];
14953
+ } else if (value && value.length > 0) {
14954
+ let command;
14955
+ const quoteMatch = value.match(/^(["'])(.*)\1/);
14956
+ if (quoteMatch) {
14957
+ command = quoteMatch[2] ?? "";
14958
+ } else {
14959
+ command = value.replace(/\s+#.*$/, "").trim();
14960
+ }
14961
+ if (command.length > 0 && !command.startsWith("#")) {
14962
+ commands.push(command);
14963
+ }
14964
+ }
14965
+ }
14886
14966
  }
14887
14967
  }
14888
- return {
14889
- decision: "allow",
14890
- matchedRule: null
14891
- };
14968
+ flushContinuation();
14969
+ if (isFoldedBlock) {
14970
+ flushFoldedBlock();
14971
+ }
14972
+ return commands;
14892
14973
  }
14893
14974
  /**
14894
- * Escapes ASCII control characters in a path before embedding it in an error
14895
- * message. Prevents log/terminal injection when the path originates from an
14896
- * environment variable (e.g. AGENTSHELL_POLICY_PATH).
14897
- */ function sanitizePath(path) {
14898
- // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
14899
- return path.replace(/[\u0000-\u001f\u007f]/g, (ch)=>{
14900
- return `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
14901
- });
14902
- }
14903
- function resolvePolicyPath(env, repoRoot) {
14904
- const override = env.AGENTSHELL_POLICY_PATH;
14905
- if (override !== undefined && override !== "") {
14906
- if ((0,external_node_path_namespaceObject.isAbsolute)(override)) {
14907
- // Absolute path — use as-is (will be validated after realpath)
14908
- return {
14909
- path: override,
14910
- isOverride: true
14911
- };
14912
- }
14913
- // Relative path — resolve relative to repo root
14914
- return {
14915
- path: (0,external_node_path_namespaceObject.join)(repoRoot, override),
14916
- isOverride: true
14917
- };
14975
+ * Parse mise.toml to extract task definitions.
14976
+ * Uses simple line-based parsing for [tasks.*] sections.
14977
+ */ async function discoverMiseTasks(targetDir) {
14978
+ const miseTomlPath = (0,external_node_path_namespaceObject.join)(targetDir, "mise.toml");
14979
+ if (!await fileExists(miseTomlPath)) {
14980
+ return [];
14918
14981
  }
14919
- return {
14920
- path: (0,external_node_path_namespaceObject.join)(repoRoot, DEFAULT_POLICY_SUBPATH),
14921
- isOverride: false
14922
- };
14923
- }
14924
- async function loadPolicy(env, deps) {
14925
- const rawRepoRoot = deps.getRepositoryRoot();
14926
- const repoRoot = await deps.realpath(rawRepoRoot);
14927
- const { path: candidatePath, isOverride } = resolvePolicyPath(env, repoRoot);
14928
- let resolvedPath;
14929
14982
  try {
14930
- resolvedPath = await deps.realpath(candidatePath);
14931
- } catch (error) {
14932
- if (isPathNotFoundError(error)) {
14933
- if (isOverride) {
14934
- throw new Error(`Policy override path does not exist: ${sanitizePath(candidatePath)}`);
14935
- }
14936
- return null;
14983
+ const fileStat = await (0,promises_namespaceObject.stat)(miseTomlPath);
14984
+ if (fileStat.size > MAX_FILE_SIZE_BYTES) {
14985
+ return [];
14937
14986
  }
14938
- throw error;
14939
- }
14940
- if (!isWithinProjectRoot(resolvedPath, repoRoot)) {
14941
- throw new Error(`Policy file path resolves outside the repository root: ${sanitizePath(resolvedPath)}`);
14987
+ const content = await (0,promises_namespaceObject.readFile)(miseTomlPath, "utf-8");
14988
+ return parseMiseTomlTasks(content);
14989
+ } catch {
14990
+ return [];
14942
14991
  }
14943
- let content;
14944
- try {
14945
- content = await deps.readFile(resolvedPath, "utf-8");
14946
- } catch (error) {
14947
- if (isPathNotFoundError(error)) {
14948
- if (isOverride) {
14949
- throw new Error(`Policy override path does not exist: ${sanitizePath(resolvedPath)}`);
14992
+ }
14993
+ function parseMiseTomlTasks(content) {
14994
+ const tasks = [];
14995
+ const lines = content.split("\n");
14996
+ let currentTaskName = null;
14997
+ let inMultiLineRun = false;
14998
+ let multiLineCommand = "";
14999
+ for (const line of lines){
15000
+ const trimmed = line.trim();
15001
+ if (inMultiLineRun) {
15002
+ if (trimmed === '"""' || trimmed === "'''") {
15003
+ inMultiLineRun = false;
15004
+ const cmd = multiLineCommand.trim();
15005
+ if (currentTaskName !== null && cmd.length > 0) {
15006
+ const firstLine = cmd.split("\n").map((l)=>l.trim()).find((l)=>l.length > 0);
15007
+ if (firstLine) {
15008
+ tasks.push({
15009
+ name: currentTaskName,
15010
+ command: firstLine
15011
+ });
15012
+ }
15013
+ }
15014
+ multiLineCommand = "";
15015
+ continue;
15016
+ }
15017
+ multiLineCommand += `${trimmed}\n`;
15018
+ continue;
15019
+ }
15020
+ const sectionMatch = trimmed.match(/^\[tasks\.([^\]]+)\]$/);
15021
+ if (sectionMatch?.[1]) {
15022
+ currentTaskName = sectionMatch[1];
15023
+ continue;
15024
+ }
15025
+ if (trimmed.startsWith("[") && !trimmed.startsWith("[tasks.")) {
15026
+ currentTaskName = null;
15027
+ continue;
15028
+ }
15029
+ if (currentTaskName !== null) {
15030
+ if (/^run\s*=\s*"""/.test(trimmed) || /^run\s*=\s*'''/.test(trimmed)) {
15031
+ inMultiLineRun = true;
15032
+ multiLineCommand = "";
15033
+ continue;
15034
+ }
15035
+ const runMatch = trimmed.match(/^run\s*=\s*(?:"([^"]*)"|'([^']*)')/);
15036
+ if (runMatch) {
15037
+ const command = runMatch[1] ?? runMatch[2] ?? "";
15038
+ if (command.length > 0) {
15039
+ tasks.push({
15040
+ name: currentTaskName,
15041
+ command
15042
+ });
15043
+ }
14950
15044
  }
14951
- return null;
14952
15045
  }
14953
- throw error;
14954
- }
14955
- let parsed;
14956
- try {
14957
- parsed = JSON.parse(content);
14958
- } catch {
14959
- throw new Error(`Invalid JSON in policy file ${sanitizePath(resolvedPath)}: file exists but contains malformed JSON`);
14960
- }
14961
- return PolicyConfigSchema.parse(parsed);
14962
- }
14963
-
14964
- ;// CONCATENATED MODULE: ./src/actor.ts
14965
- // Best-effort agent detection env vars (Phase 1):
14966
- // - CLAUDE_CODE: Set by Claude Code (`CLAUDE_CODE=1`) in its shell sessions
14967
- // - COPILOT_AGENT: Set by GitHub Copilot coding agent in its shell sessions
14968
- // - COPILOT_CLI: Set by GitHub Copilot CLI (`COPILOT_CLI=1`) in its shell sessions
14969
- // - COPILOT_CLI_BINARY_VERSION: Set by GitHub Copilot CLI (e.g. `COPILOT_CLI_BINARY_VERSION=1.0.4`)
14970
- // If these prove incorrect, the detection rule should be removed entirely.
14971
- /**
14972
- * Determines who initiated a script execution based on environment variables.
14973
- *
14974
- * Detection priority (first match wins):
14975
- * 1. Explicit override via AGENTSHELL_ACTOR
14976
- * 2. CI detection via GITHUB_ACTIONS
14977
- * 3. Known coding agent detection (Claude Code, GitHub Copilot)
14978
- * 4. Fallback to "human"
14979
- */ function detectActor(env) {
14980
- const override = env.AGENTSHELL_ACTOR;
14981
- if (override !== undefined && override !== "") {
14982
- return override;
14983
- }
14984
- if (env.GITHUB_ACTIONS === "true") {
14985
- return "ci";
14986
- }
14987
- if (env.CLAUDE_CODE !== undefined && env.CLAUDE_CODE !== "") {
14988
- return "claude-code";
14989
- }
14990
- if (env.COPILOT_AGENT !== undefined && env.COPILOT_AGENT !== "") {
14991
- return "copilot";
14992
- }
14993
- if (env.COPILOT_CLI !== undefined && env.COPILOT_CLI !== "" || env.COPILOT_CLI_BINARY_VERSION !== undefined && env.COPILOT_CLI_BINARY_VERSION !== "") {
14994
- return "copilot";
14995
15046
  }
14996
- return "human";
15047
+ return tasks;
14997
15048
  }
14998
-
14999
- ;// CONCATENATED MODULE: ./src/env-capture.ts
15000
- const TAG_PREFIX = "AGENTSHELL_TAG_";
15001
- const MAX_VALUE_BYTES = 1024;
15002
- const MAX_TAGS = 50;
15003
- const TRUNCATION_SUFFIX = "…[truncated]";
15004
- const PROTOTYPE_POLLUTION_KEYS = new Set([
15005
- "__proto__",
15006
- "constructor",
15007
- "prototype"
15008
- ]);
15009
- const ALLOWLIST_PREFIXES = [
15010
- "npm_lifecycle_",
15011
- "github_",
15012
- "agentshell_"
15013
- ];
15014
- const ALLOWLIST_EXACT = new Set([
15015
- "npm_package_name",
15016
- "npm_package_version",
15017
- "node_env",
15018
- "ci"
15019
- ]);
15020
- const BLOCKLIST_PATTERNS = [
15021
- "secret",
15022
- "token",
15023
- "key",
15024
- "password",
15025
- "credential",
15026
- "auth"
15027
- ];
15028
- function isAllowlisted(name) {
15029
- const lower = name.toLowerCase();
15030
- if (ALLOWLIST_EXACT.has(lower)) {
15031
- return true;
15049
+ async function detectLanguages(targetDir) {
15050
+ const detected = new Set();
15051
+ for (const [filename, language] of Object.entries(LANGUAGE_MARKERS)){
15052
+ if (await fileExists((0,external_node_path_namespaceObject.join)(targetDir, filename))) {
15053
+ detected.add(language);
15054
+ }
15032
15055
  }
15033
- return ALLOWLIST_PREFIXES.some((prefix)=>lower.startsWith(prefix));
15034
- }
15035
- function isBlocklisted(name) {
15036
- const lower = name.toLowerCase();
15037
- return BLOCKLIST_PATTERNS.some((pattern)=>lower.includes(pattern));
15038
- }
15039
- function isTagVariable(name) {
15040
- return name.startsWith(TAG_PREFIX);
15056
+ return [
15057
+ ...detected
15058
+ ];
15041
15059
  }
15042
- function truncateValue(value) {
15043
- const bytes = Buffer.byteLength(value, "utf-8");
15044
- if (bytes <= MAX_VALUE_BYTES) {
15045
- return {
15046
- value,
15047
- truncated: false
15048
- };
15049
- }
15050
- const buf = Buffer.from(value, "utf-8");
15051
- const sliced = buf.subarray(0, MAX_VALUE_BYTES).toString("utf-8");
15060
+ /**
15061
+ * Scans a project directory to discover tools, commands, and languages.
15062
+ * Uses static file analysis without requiring external dependencies.
15063
+ */ async function scanProject(targetDir) {
15064
+ const [scripts, workflowCommands, miseTasks, languages] = await Promise.all([
15065
+ discoverScripts(targetDir),
15066
+ discoverWorkflowCommands(targetDir),
15067
+ discoverMiseTasks(targetDir),
15068
+ detectLanguages(targetDir)
15069
+ ]);
15052
15070
  return {
15053
- value: `${sliced}${TRUNCATION_SUFFIX}`,
15054
- truncated: true
15071
+ scripts,
15072
+ workflowCommands,
15073
+ miseTasks,
15074
+ languages
15055
15075
  };
15056
15076
  }
15057
- function captureEnv(env) {
15058
- const result = Object.create(null);
15059
- let anyTruncated = false;
15060
- for (const [name, value] of Object.entries(env)){
15061
- if (value === undefined) continue;
15062
- if (!isAllowlisted(name)) continue;
15063
- if (isBlocklisted(name)) continue;
15064
- if (isTagVariable(name)) continue;
15065
- const { value: finalValue, truncated } = truncateValue(value);
15066
- result[name] = finalValue;
15067
- if (truncated) anyTruncated = true;
15068
- }
15069
- if (anyTruncated) {
15070
- result._env_truncated = "true";
15071
- }
15072
- return result;
15073
- }
15074
- function captureTags(env) {
15075
- const result = Object.create(null);
15076
- let anyTruncatedOrDiscarded = false;
15077
- const entries = [];
15078
- for (const [name, value] of Object.entries(env)){
15079
- if (value === undefined) continue;
15080
- if (!name.startsWith(TAG_PREFIX)) continue;
15081
- const tagKey = name.slice(TAG_PREFIX.length).toLowerCase();
15082
- if (PROTOTYPE_POLLUTION_KEYS.has(tagKey)) continue;
15083
- entries.push([
15084
- tagKey,
15085
- value
15086
- ]);
15087
- }
15088
- entries.sort((a, b)=>a[0].localeCompare(b[0]));
15089
- if (entries.length > MAX_TAGS) {
15090
- anyTruncatedOrDiscarded = true;
15091
- entries.length = MAX_TAGS;
15092
- }
15093
- for (const [key, rawValue] of entries){
15094
- const { value, truncated } = truncateValue(rawValue);
15095
- result[key] = value;
15096
- if (truncated) anyTruncatedOrDiscarded = true;
15097
- }
15098
- if (anyTruncatedOrDiscarded) {
15099
- result._tags_truncated = "true";
15100
- }
15101
- return result;
15102
- }
15103
15077
 
15104
- ;// CONCATENATED MODULE: ./src/telemetry.ts
15105
- // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
15078
+ ;// CONCATENATED MODULE: ./src/policy-init.ts
15106
15079
 
15107
15080
 
15108
15081
 
15109
15082
 
15110
15083
 
15111
- const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
15112
- const DEFAULT_EVENTS_SUBDIR = ".agent-shell/events";
15113
- async function realpathExistingAncestor(targetPath, deps) {
15114
- let current = targetPath;
15115
- while(true){
15116
- try {
15117
- return await deps.realpath(current);
15118
- } catch (err) {
15119
- if (!isPathNotFoundError(err)) throw err;
15120
- const parent = (0,external_node_path_namespaceObject.dirname)(current);
15121
- if (parent === current) return null;
15122
- current = parent;
15123
- }
15124
- }
15125
- }
15126
- function resolveSessionId(env, deps) {
15127
- const provided = env.AGENTSHELL_SESSION_ID;
15128
- if (provided === undefined || provided === "") {
15129
- return deps.randomUUID();
15130
- }
15131
- if (provided.includes("..") || provided.includes("/") || provided.includes("\\") || !SESSION_ID_PATTERN.test(provided)) {
15132
- deps.writeStderr(`agent-shell: invalid AGENTSHELL_SESSION_ID "${provided}", generating new ID\n`);
15133
- return deps.randomUUID();
15134
- }
15135
- return provided;
15136
- }
15137
- async function resolveWriteEventsDir(env, deps) {
15138
- const projectRoot = deps.cwd();
15139
- const defaultDir = (0,external_node_path_namespaceObject.join)(projectRoot, DEFAULT_EVENTS_SUBDIR);
15140
- const logDir = env.AGENTSHELL_LOG_DIR;
15141
- if (logDir !== undefined && logDir !== "") {
15142
- // Canonicalize projectRoot for real-path comparisons; handles symlinked cwd
15143
- const projectRootReal = await deps.realpath(projectRoot);
15144
- // Reject external paths (allowing either logical or real project root)
15145
- const resolvedLogical = (0,external_node_path_namespaceObject.resolve)(projectRoot, logDir);
15146
- if (!isWithinProjectRoot(resolvedLogical, projectRoot) && !isWithinProjectRoot(resolvedLogical, projectRootReal)) {
15147
- deps.writeStderr(`agent-shell: AGENTSHELL_LOG_DIR resolves outside project root, using default\n`);
15148
- await deps.mkdir(defaultDir, {
15149
- recursive: true
15150
- });
15151
- return defaultDir;
15084
+ // Exact-match entries for agent-shell's own commands prevent the preToolUse
15085
+ // hook from blocking its sibling hooks. Wildcard entries (ending with *) cover
15086
+ // common read-only git commands. The final allow list is sorted alphabetically.
15087
+ const DEFAULT_SAFE_COMMANDS = [
15088
+ "agent-shell policy-check",
15089
+ "agent-shell record",
15090
+ "git status *",
15091
+ "git diff *",
15092
+ "git log *",
15093
+ "git show *",
15094
+ "git branch --show-current",
15095
+ "git branch --list *",
15096
+ "git rev-parse *",
15097
+ "pwd"
15098
+ ];
15099
+ const DEFAULT_DENY_RULES = [
15100
+ "rm -rf *",
15101
+ "sudo *"
15102
+ ];
15103
+ const POLICY_SUBPATH = ".github/hooks/agent-shell/policy.json";
15104
+ const HOOKS_SUBPATH = ".github/hooks/agent-shell/hooks.json";
15105
+ /**
15106
+ * Extracts the script or task name from a command string, skipping any
15107
+ * flags (tokens starting with `-`) that appear between the prefix and
15108
+ * the actual name. For example, `npm run -s build` or `npm run --silent build`
15109
+ * both return `build`. Splits on any whitespace to avoid empty tokens from
15110
+ * multiple consecutive spaces.
15111
+ */ function extractNameAfterPrefix(cmd, prefix) {
15112
+ const tokens = cmd.slice(prefix.length).trim().split(/\s+/);
15113
+ for (const token of tokens){
15114
+ if (token.length > 0 && !token.startsWith("-")) {
15115
+ return token;
15152
15116
  }
15153
- // Validate existing ancestor realpath before mkdir to prevent symlink escape
15154
- const ancestorReal = await realpathExistingAncestor(resolvedLogical, deps);
15155
- if (ancestorReal === null || !isWithinProjectRoot(ancestorReal, projectRootReal)) {
15156
- deps.writeStderr(`agent-shell: AGENTSHELL_LOG_DIR resolves outside project root via ancestor symlink, using default\n`);
15157
- await deps.mkdir(defaultDir, {
15158
- recursive: true
15159
- });
15160
- return defaultDir;
15117
+ }
15118
+ return "";
15119
+ }
15120
+ /**
15121
+ * Generates a policy configuration from project scan results.
15122
+ * Creates an allow list of commands discovered in the project,
15123
+ * plus common safe defaults. Includes standard deny rules.
15124
+ *
15125
+ * Allow rules use exact match by default to prevent shell
15126
+ * metacharacter bypass (e.g. `npm test && curl evil`).
15127
+ * Wildcard `*` is only used for commands where subcommand
15128
+ * arguments are inherently expected and the base command
15129
+ * is genuinely read-only (e.g. `git status *`).
15130
+ */ function generatePolicy(scanResult) {
15131
+ const allowSet = new Set();
15132
+ for (const cmd of DEFAULT_SAFE_COMMANDS){
15133
+ allowSet.add(cmd);
15134
+ }
15135
+ for (const script of scanResult.scripts){
15136
+ const name = script.name.trim();
15137
+ if (name.length === 0) {
15138
+ continue;
15161
15139
  }
15162
- await deps.mkdir(resolvedLogical, {
15163
- recursive: true
15164
- });
15165
- const resolved = await deps.realpath(resolvedLogical);
15166
- if (!isWithinProjectRoot(resolved, projectRootReal)) {
15167
- deps.writeStderr(`agent-shell: AGENTSHELL_LOG_DIR resolves outside project root, using default\n`);
15168
- await deps.mkdir(defaultDir, {
15169
- recursive: true
15170
- });
15171
- return defaultDir;
15140
+ if (SHELL_METACHAR_PATTERN.test(name)) {
15141
+ continue;
15142
+ }
15143
+ if (name === "test") {
15144
+ allowSet.add("npm test");
15145
+ } else {
15146
+ allowSet.add(`npm run ${name}`);
15172
15147
  }
15173
- return resolved;
15174
15148
  }
15175
- await deps.mkdir(defaultDir, {
15149
+ for (const cmd of scanResult.workflowCommands){
15150
+ if (cmd === "npm test" || cmd.startsWith("npm test ")) {
15151
+ allowSet.add("npm test");
15152
+ if (cmd !== "npm test" && !SHELL_METACHAR_PATTERN.test(cmd)) {
15153
+ allowSet.add(cmd);
15154
+ }
15155
+ } else if (cmd.startsWith("npm run ")) {
15156
+ const scriptName = extractNameAfterPrefix(cmd, "npm run ");
15157
+ if (scriptName) {
15158
+ allowSet.add(`npm run ${scriptName}`);
15159
+ if (cmd !== `npm run ${scriptName}` && !SHELL_METACHAR_PATTERN.test(cmd)) {
15160
+ allowSet.add(cmd);
15161
+ }
15162
+ } else if (!SHELL_METACHAR_PATTERN.test(cmd)) {
15163
+ allowSet.add(cmd);
15164
+ }
15165
+ } else if (cmd === "npm ci" || cmd === "npm install") {
15166
+ allowSet.add(cmd);
15167
+ } else if (cmd.startsWith("npx ")) {
15168
+ if (!SHELL_METACHAR_PATTERN.test(cmd)) {
15169
+ allowSet.add(cmd);
15170
+ }
15171
+ } else if (cmd.startsWith("mise run ")) {
15172
+ const taskName = extractNameAfterPrefix(cmd, "mise run ");
15173
+ if (taskName) {
15174
+ allowSet.add(`mise run ${taskName}`);
15175
+ if (cmd !== `mise run ${taskName}` && !SHELL_METACHAR_PATTERN.test(cmd)) {
15176
+ allowSet.add(cmd);
15177
+ }
15178
+ } else if (!SHELL_METACHAR_PATTERN.test(cmd)) {
15179
+ allowSet.add(cmd);
15180
+ }
15181
+ } else {
15182
+ if (!SHELL_METACHAR_PATTERN.test(cmd)) {
15183
+ allowSet.add(cmd);
15184
+ }
15185
+ }
15186
+ }
15187
+ for (const task of scanResult.miseTasks){
15188
+ const taskName = task.name.trim();
15189
+ if (taskName.length > 0 && !SHELL_METACHAR_PATTERN.test(taskName)) {
15190
+ allowSet.add(`mise run ${taskName}`);
15191
+ }
15192
+ }
15193
+ if (scanResult.miseTasks.length > 0) {
15194
+ allowSet.add("mise install");
15195
+ }
15196
+ const allow = [
15197
+ ...allowSet
15198
+ ].sort();
15199
+ return {
15200
+ allow,
15201
+ deny: [
15202
+ ...DEFAULT_DENY_RULES
15203
+ ]
15204
+ };
15205
+ }
15206
+ /**
15207
+ * Generates the Copilot hooks.json configuration with agent-shell
15208
+ * hooks based on the provided feature flags.
15209
+ * Defaults to policyCheck only for backward compatibility.
15210
+ */ function generateHooksConfig(options = {}) {
15211
+ const { flightRecorder, policyCheck } = {
15212
+ policyCheck: true,
15213
+ ...options
15214
+ };
15215
+ const config = {
15216
+ version: 1,
15217
+ hooks: {}
15218
+ };
15219
+ if (policyCheck) {
15220
+ config.hooks.preToolUse = [
15221
+ {
15222
+ type: "command",
15223
+ bash: "agent-shell policy-check",
15224
+ timeoutSec: 30
15225
+ }
15226
+ ];
15227
+ }
15228
+ if (flightRecorder) {
15229
+ config.hooks.postToolUse = [
15230
+ {
15231
+ type: "command",
15232
+ bash: "agent-shell record",
15233
+ timeoutSec: 30
15234
+ }
15235
+ ];
15236
+ }
15237
+ return config;
15238
+ }
15239
+ /**
15240
+ * Writes a file atomically, skipping if the file already exists.
15241
+ * Uses the `wx` (exclusive create) flag to avoid TOCTOU races
15242
+ * between a check and write.
15243
+ */ async function writeFileIfNotExists(filePath, parentDir, content, subpath, writeStdout) {
15244
+ await (0,promises_namespaceObject.mkdir)(parentDir, {
15176
15245
  recursive: true
15177
15246
  });
15178
- return defaultDir;
15247
+ try {
15248
+ await (0,promises_namespaceObject.writeFile)(filePath, content, {
15249
+ flag: "wx"
15250
+ });
15251
+ writeStdout(`Created ${subpath}\n`);
15252
+ } catch (error) {
15253
+ if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
15254
+ writeStdout(`Skipping ${subpath} — file already exists\n`);
15255
+ } else {
15256
+ throw error;
15257
+ }
15258
+ }
15179
15259
  }
15180
- async function writeEvent(eventsDir, sessionId, event, deps) {
15181
- const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, `${sessionId}.jsonl`);
15182
- const line = `${JSON.stringify(event)}\n`;
15183
- await deps.appendFile(filePath, line);
15260
+ /**
15261
+ * Handles the `policy --init` command.
15262
+ * Scans the project, generates a policy and hooks configuration,
15263
+ * and writes them to disk. Optionally uses @github/copilot-sdk
15264
+ * for AI-enhanced analysis when available.
15265
+ */ async function handlePolicyInit(deps) {
15266
+ const repoRoot = deps.getRepositoryRoot();
15267
+ deps.writeStdout("Scanning project...\n");
15268
+ const scanResult = await scanProject(repoRoot);
15269
+ deps.writeStdout(`Discovered: ${scanResult.scripts.length} npm script(s), ` + `${scanResult.workflowCommands.length} workflow command(s), ` + `${scanResult.miseTasks.length} mise task(s), ` + `${scanResult.languages.length} language(s)\n`);
15270
+ const policy = generatePolicy(scanResult);
15271
+ const enhanced = await enhanceWithCopilot(scanResult, repoRoot, deps.writeStderr, deps.model);
15272
+ if (enhanced !== null) {
15273
+ deps.writeStdout("Enhanced with Copilot analysis\n");
15274
+ if (enhanced.additionalAllowRules.length > 0) {
15275
+ deps.writeStdout("\nSuggested additional allow rules from Copilot (not auto-applied):\n");
15276
+ for (const rule of enhanced.additionalAllowRules){
15277
+ if (isSafeCommand(rule)) {
15278
+ deps.writeStdout(` - ${sanitizeOutput(rule)}\n`);
15279
+ } else {
15280
+ deps.writeStdout(` - [UNSAFE, skipped] ${sanitizeOutput(rule)}\n`);
15281
+ }
15282
+ }
15283
+ }
15284
+ if (enhanced.suggestions.length > 0) {
15285
+ deps.writeStdout("\nSuggestions from Copilot:\n");
15286
+ for (const suggestion of enhanced.suggestions){
15287
+ deps.writeStdout(` - ${sanitizeOutput(suggestion)}\n`);
15288
+ }
15289
+ }
15290
+ }
15291
+ const hooksConfig = generateHooksConfig();
15292
+ const policyPath = (0,external_node_path_namespaceObject.join)(repoRoot, POLICY_SUBPATH);
15293
+ const hooksPath = (0,external_node_path_namespaceObject.join)(repoRoot, HOOKS_SUBPATH);
15294
+ const policyContent = `${JSON.stringify(policy, null, 2)}\n`;
15295
+ const hooksContent = `${JSON.stringify(hooksConfig, null, 2)}\n`;
15296
+ await writeFileIfNotExists(policyPath, (0,external_node_path_namespaceObject.join)(repoRoot, ".github", "hooks", "agent-shell"), policyContent, POLICY_SUBPATH, deps.writeStdout);
15297
+ await writeFileIfNotExists(hooksPath, (0,external_node_path_namespaceObject.join)(repoRoot, ".github", "hooks", "agent-shell"), hooksContent, HOOKS_SUBPATH, deps.writeStdout);
15298
+ deps.writeStdout("\n--- Proposed Policy ---\n");
15299
+ deps.writeStdout(`${sanitizeOutput(JSON.stringify(policy, null, 2))}\n`);
15300
+ deps.writeStdout("\n--- Hook Configuration ---\n");
15301
+ deps.writeStdout(`${sanitizeOutput(JSON.stringify(hooksConfig, null, 2))}\n`);
15184
15302
  }
15185
- async function emitScriptEndEvent(options, deps) {
15186
- const sessionId = resolveSessionId(options.env, deps);
15187
- const eventsDir = await resolveWriteEventsDir(options.env, deps);
15188
- const capturedEnv = captureEnv(options.env);
15189
- const tags = captureTags(options.env);
15190
- const event = {
15191
- v: SCHEMA_VERSION,
15192
- session_id: sessionId,
15193
- event: "script_end",
15194
- command: options.command,
15195
- actor: detectActor(options.env),
15196
- exit_code: options.result.exitCode,
15197
- signal: options.result.signal,
15198
- duration_ms: options.result.durationMs,
15199
- timestamp: deps.now(),
15200
- env: capturedEnv,
15201
- tags,
15202
- ...options.env.npm_lifecycle_event && {
15203
- script: options.env.npm_lifecycle_event
15204
- },
15205
- ...options.env.npm_package_name && {
15206
- package: options.env.npm_package_name
15207
- },
15208
- ...options.env.npm_package_version && {
15209
- package_version: options.env.npm_package_version
15303
+
15304
+ ;// CONCATENATED MODULE: ./src/types.ts
15305
+ // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
15306
+
15307
+ const SCHEMA_VERSION = 1;
15308
+ const baseFields = {
15309
+ v: literal(1),
15310
+ session_id: schemas_string(),
15311
+ command: schemas_string(),
15312
+ actor: schemas_string(),
15313
+ timestamp: schemas_string(),
15314
+ env: record(schemas_string(), schemas_string()),
15315
+ tags: record(schemas_string(), schemas_string())
15316
+ };
15317
+ const ScriptEndEventSchema = schemas_object({
15318
+ ...baseFields,
15319
+ event: literal("script_end"),
15320
+ script: schemas_string().optional(),
15321
+ package: schemas_string().optional(),
15322
+ package_version: schemas_string().optional(),
15323
+ exit_code: schemas_number().int(),
15324
+ signal: schemas_string().nullable(),
15325
+ duration_ms: schemas_number()
15326
+ }).strict();
15327
+ const ShimErrorEventSchema = schemas_object({
15328
+ ...baseFields,
15329
+ event: literal("shim_error")
15330
+ }).strict();
15331
+ const PolicyDecisionEventSchema = schemas_object({
15332
+ ...baseFields,
15333
+ event: literal("policy_decision"),
15334
+ decision: schemas_enum([
15335
+ "allow",
15336
+ "deny"
15337
+ ]),
15338
+ matched_rule: schemas_string().nullable()
15339
+ }).strict();
15340
+ const ToolUseEventSchema = schemas_object({
15341
+ ...baseFields,
15342
+ event: literal("tool_use"),
15343
+ tool_name: schemas_string()
15344
+ }).strict();
15345
+ const ScriptEventSchema = discriminatedUnion("event", [
15346
+ ScriptEndEventSchema,
15347
+ ShimErrorEventSchema,
15348
+ PolicyDecisionEventSchema,
15349
+ ToolUseEventSchema
15350
+ ]);
15351
+ const MAX_POLICY_RULES = 10_000;
15352
+ const MAX_RULE_LENGTH = 1024;
15353
+ const policyRuleArray = schemas_array(schemas_string().max(MAX_RULE_LENGTH)).max(MAX_POLICY_RULES);
15354
+ const PolicyConfigSchema = schemas_object({
15355
+ allow: policyRuleArray.optional(),
15356
+ deny: policyRuleArray.default(()=>[])
15357
+ }).strict();
15358
+ // NOTE: This schema mirrors CopilotHookCommandSchema in @lousy-agents/core
15359
+ // (packages/core/src/entities/copilot-hook-schema.ts). Keep them aligned.
15360
+ // agent-shell cannot import from core since it is a standalone published binary.
15361
+ const MAX_HOOKS_PER_EVENT = 100;
15362
+ /** Regex that allows standard env var names and rejects __proto__ (the prototype-polluting key). */ const ENV_KEY_PATTERN = /^(?!__proto__$)[a-zA-Z_][a-zA-Z0-9_]*$/;
15363
+ const HookCommandSchema = schemas_object({
15364
+ type: literal("command"),
15365
+ bash: schemas_string().min(1, "Hook bash command must not be empty").optional(),
15366
+ powershell: schemas_string().min(1, "Hook PowerShell command must not be empty").optional(),
15367
+ cwd: schemas_string().optional(),
15368
+ timeoutSec: schemas_number().positive().optional(),
15369
+ env: record(schemas_string().regex(ENV_KEY_PATTERN, "Hook env key must be a valid identifier (no prototype-polluting keys)"), schemas_string()).optional()
15370
+ }).strict().refine((data)=>Boolean(data.bash) || Boolean(data.powershell), {
15371
+ message: "At least one of 'bash' or 'powershell' must be provided and non-empty"
15372
+ });
15373
+ const hookArray = schemas_array(HookCommandSchema).max(MAX_HOOKS_PER_EVENT);
15374
+ const HooksConfigSchema = schemas_object({
15375
+ version: literal(1),
15376
+ hooks: schemas_object({
15377
+ sessionStart: hookArray.optional(),
15378
+ userPromptSubmitted: hookArray.optional(),
15379
+ preToolUse: hookArray.optional(),
15380
+ postToolUse: hookArray.optional(),
15381
+ sessionEnd: hookArray.optional()
15382
+ }).strict()
15383
+ }).strict();
15384
+
15385
+ ;// CONCATENATED MODULE: ./src/init-command.ts
15386
+
15387
+
15388
+
15389
+
15390
+
15391
+
15392
+ const init_command_HOOKS_SUBPATH = ".github/hooks/agent-shell/hooks.json";
15393
+ const init_command_POLICY_SUBPATH = ".github/hooks/agent-shell/policy.json";
15394
+ const HOOKS_PARENT = ".github/hooks/agent-shell";
15395
+ const AGENT_SHELL_POLICY_CHECK = "agent-shell policy-check";
15396
+ const AGENT_SHELL_RECORD = "agent-shell record";
15397
+ const AGENT_SHELL_ALLOW_ENTRIES = [
15398
+ AGENT_SHELL_POLICY_CHECK,
15399
+ AGENT_SHELL_RECORD
15400
+ ];
15401
+ function hasHookCommand(hooks, expectedCommand) {
15402
+ if (!Array.isArray(hooks)) {
15403
+ return false;
15404
+ }
15405
+ return hooks.some((hook)=>{
15406
+ if (typeof hook !== "object" || hook === null) {
15407
+ return false;
15210
15408
  }
15211
- };
15212
- await writeEvent(eventsDir, sessionId, event, deps);
15409
+ const bashMatch = "bash" in hook && typeof hook.bash === "string" && hook.bash === expectedCommand;
15410
+ const powershellMatch = "powershell" in hook && typeof hook.powershell === "string" && hook.powershell === expectedCommand;
15411
+ return bashMatch || powershellMatch;
15412
+ });
15213
15413
  }
15214
- async function emitShimErrorEvent(options, deps) {
15215
- const sessionId = resolveSessionId(options.env, deps);
15216
- const eventsDir = await resolveWriteEventsDir(options.env, deps);
15217
- const capturedEnv = captureEnv(options.env);
15218
- const tags = captureTags(options.env);
15219
- const event = {
15220
- v: SCHEMA_VERSION,
15221
- session_id: sessionId,
15222
- event: "shim_error",
15223
- command: options.command,
15224
- actor: detectActor(options.env),
15225
- timestamp: deps.now(),
15226
- env: capturedEnv,
15227
- tags
15414
+ /**
15415
+ * Ensures agent-shell's own commands are in an existing policy.json allow list.
15416
+ * Returns a discriminated union:
15417
+ * - `patched` with new content when entries were added
15418
+ * - `unchanged` when all entries are already present
15419
+ * - `invalid` when the file cannot be parsed or fails schema validation
15420
+ */ function ensureAgentShellAllowed(content) {
15421
+ let parsed;
15422
+ try {
15423
+ parsed = JSON.parse(content);
15424
+ } catch {
15425
+ return {
15426
+ status: "invalid",
15427
+ reason: "JSON parse error"
15428
+ };
15429
+ }
15430
+ const result = PolicyConfigSchema.safeParse(parsed);
15431
+ if (!result.success) {
15432
+ return {
15433
+ status: "invalid",
15434
+ reason: "policy schema validation failed"
15435
+ };
15436
+ }
15437
+ const policy = result.data;
15438
+ const allow = policy.allow ? [
15439
+ ...policy.allow
15440
+ ] : [];
15441
+ const missing = AGENT_SHELL_ALLOW_ENTRIES.filter((entry)=>!allow.includes(entry));
15442
+ if (missing.length === 0) {
15443
+ return {
15444
+ status: "unchanged"
15445
+ };
15446
+ }
15447
+ allow.push(...missing);
15448
+ const patched = {
15449
+ ...policy,
15450
+ allow
15451
+ };
15452
+ return {
15453
+ status: "patched",
15454
+ content: `${JSON.stringify(patched, null, 2)}\n`
15228
15455
  };
15229
- await writeEvent(eventsDir, sessionId, event, deps);
15230
15456
  }
15231
- async function emitPolicyDecisionEvent(options, deps) {
15232
- const depsWithProjectRoot = {
15233
- ...deps,
15234
- cwd: ()=>options.projectRoot
15457
+ function detectExistingFeatures(config) {
15458
+ return {
15459
+ hasPreToolUse: hasHookCommand(config.hooks.preToolUse, AGENT_SHELL_POLICY_CHECK),
15460
+ hasPostToolUse: hasHookCommand(config.hooks.postToolUse, AGENT_SHELL_RECORD)
15235
15461
  };
15236
- const sessionId = resolveSessionId(options.env, deps);
15237
- const eventsDir = await resolveWriteEventsDir(options.env, depsWithProjectRoot);
15238
- const capturedEnv = captureEnv(options.env);
15239
- const tags = captureTags(options.env);
15240
- const event = {
15241
- v: SCHEMA_VERSION,
15242
- session_id: sessionId,
15243
- event: "policy_decision",
15244
- command: options.command,
15245
- decision: options.decision,
15246
- matched_rule: options.matched_rule,
15247
- actor: detectActor(options.env),
15248
- timestamp: deps.now(),
15249
- env: capturedEnv,
15250
- tags
15462
+ }
15463
+ async function loadExistingHooksConfig(hooksPath, deps) {
15464
+ try {
15465
+ const raw = await deps.readFile(hooksPath, "utf-8");
15466
+ const parsed = JSON.parse(raw);
15467
+ return {
15468
+ config: HooksConfigSchema.parse(parsed),
15469
+ error: false
15470
+ };
15471
+ } catch (err) {
15472
+ if (isPathNotFoundError(err)) {
15473
+ return {
15474
+ config: null,
15475
+ error: false
15476
+ };
15477
+ }
15478
+ deps.writeStderr(`agent-shell: failed to read existing hooks.json: ${sanitizeForStderr(err)}\n`);
15479
+ return {
15480
+ config: null,
15481
+ error: true
15482
+ };
15483
+ }
15484
+ }
15485
+ function hasExplicitFlags(flags) {
15486
+ return flags.flightRecorder || flags.policy || flags.noFlightRecorder || flags.noPolicy;
15487
+ }
15488
+ /**
15489
+ * Resolves feature selections in explicit-flag mode.
15490
+ * Only features with an explicit --flag are enabled; unspecified features
15491
+ * are not added (existing hooks are preserved via config merge).
15492
+ */ function resolveExplicitFlagSelections(flags) {
15493
+ let enableFlightRecorder;
15494
+ let enablePolicy;
15495
+ if (flags.noFlightRecorder) {
15496
+ enableFlightRecorder = false;
15497
+ } else {
15498
+ enableFlightRecorder = flags.flightRecorder;
15499
+ }
15500
+ if (flags.noPolicy) {
15501
+ enablePolicy = false;
15502
+ } else {
15503
+ enablePolicy = flags.policy;
15504
+ }
15505
+ return {
15506
+ enableFlightRecorder,
15507
+ enablePolicy
15251
15508
  };
15252
- await writeEvent(eventsDir, sessionId, event, depsWithProjectRoot);
15253
15509
  }
15254
-
15255
- ;// CONCATENATED MODULE: ./src/policy-check.ts
15256
- // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
15257
-
15258
-
15259
-
15260
-
15261
- const TERMINAL_TOOLS = new Set([
15262
- "bash",
15263
- "zsh",
15264
- "ash",
15265
- "sh"
15266
- ]);
15267
- const HookInputSchema = schemas_object({
15268
- toolName: schemas_string(),
15269
- toolArgs: unknown().optional()
15270
- });
15271
- function allowResponse() {
15272
- return JSON.stringify({
15273
- permissionDecision: "allow"
15274
- });
15510
+ async function validatePathContainment(subpath, repoRoot, deps) {
15511
+ const resolvedPath = (0,external_node_path_namespaceObject.resolve)(repoRoot, subpath);
15512
+ if (!isWithinProjectRoot(resolvedPath, repoRoot)) {
15513
+ deps.writeStderr(`agent-shell: path ${subpath} resolves outside repository root, aborting\n`);
15514
+ return false;
15515
+ }
15516
+ // Canonicalize repoRoot first — if this fails, the containment check
15517
+ // is impossible and we must abort.
15518
+ let realRepoRoot;
15519
+ try {
15520
+ realRepoRoot = await deps.realpath(repoRoot);
15521
+ } catch (err) {
15522
+ if (!isPathNotFoundError(err)) {
15523
+ throw err;
15524
+ }
15525
+ deps.writeStderr(`agent-shell: repository root ${sanitizeForStderr(repoRoot)} is unreachable or cannot be canonicalized, aborting\n`);
15526
+ return false;
15527
+ }
15528
+ try {
15529
+ const realPath = await deps.realpath(resolvedPath);
15530
+ if (!isWithinProjectRoot(realPath, realRepoRoot)) {
15531
+ deps.writeStderr(`agent-shell: path ${subpath} resolves outside repository root via symlink, aborting\n`);
15532
+ return false;
15533
+ }
15534
+ } catch (err) {
15535
+ if (!isPathNotFoundError(err)) {
15536
+ throw err;
15537
+ }
15538
+ // Target path doesn't exist yet, which is fine for new config files
15539
+ }
15540
+ return true;
15275
15541
  }
15276
- function denyResponse(reason) {
15277
- return JSON.stringify({
15278
- permissionDecision: "deny",
15279
- permissionDecisionReason: reason
15542
+ async function writeConfigFile(repoRoot, subpath, content, deps) {
15543
+ const valid = await validatePathContainment(subpath, repoRoot, deps);
15544
+ if (!valid) {
15545
+ return false;
15546
+ }
15547
+ const filePath = (0,external_node_path_namespaceObject.join)(repoRoot, subpath);
15548
+ const parentDir = (0,external_node_path_namespaceObject.join)(repoRoot, HOOKS_PARENT);
15549
+ await deps.mkdir(parentDir, {
15550
+ recursive: true
15280
15551
  });
15281
- }
15282
- const TELEMETRY_TIMEOUT_MS = 5_000;
15283
- async function tryEmitTelemetry(deps, command, decision, matchedRule) {
15552
+ // Post-mkdir containment recheck: verify the parent directory hasn't
15553
+ // been redirected via symlink created between the pre-check and mkdir.
15554
+ let realRepoRoot;
15284
15555
  try {
15285
- const repoRoot = deps.policyDeps.getRepositoryRoot();
15286
- const emission = emitPolicyDecisionEvent({
15287
- command,
15288
- decision,
15289
- matched_rule: matchedRule,
15290
- env: deps.env,
15291
- projectRoot: repoRoot
15292
- }, deps.telemetryDeps);
15293
- // Prevent unhandled rejection if emission rejects after timeout wins the race
15294
- emission.catch(()=>{});
15295
- let timeoutHandle;
15296
- const timeout = new Promise((_, reject)=>{
15297
- timeoutHandle = setTimeout(()=>reject(new Error("telemetry write timed out")), TELEMETRY_TIMEOUT_MS);
15298
- timeoutHandle.unref();
15299
- });
15300
- try {
15301
- await Promise.race([
15302
- emission,
15303
- timeout
15304
- ]);
15305
- } finally{
15306
- if (timeoutHandle !== undefined) {
15307
- clearTimeout(timeoutHandle);
15308
- }
15556
+ realRepoRoot = await deps.realpath(repoRoot);
15557
+ } catch (err) {
15558
+ if (!isPathNotFoundError(err)) {
15559
+ throw err;
15560
+ }
15561
+ deps.writeStderr(`agent-shell: repository root ${sanitizeForStderr(repoRoot)} is unreachable or cannot be canonicalized, aborting\n`);
15562
+ return false;
15563
+ }
15564
+ try {
15565
+ const realParent = await deps.realpath(parentDir);
15566
+ if (!isWithinProjectRoot(realParent, realRepoRoot)) {
15567
+ deps.writeStderr(`agent-shell: parent directory resolves outside repository root after mkdir, aborting\n`);
15568
+ return false;
15309
15569
  }
15310
15570
  } catch (err) {
15311
- deps.writeStderr(`agent-shell: telemetry write error: ${sanitizeForStderr(err)}\n`);
15571
+ if (!isPathNotFoundError(err)) {
15572
+ throw err;
15573
+ }
15312
15574
  }
15313
- }
15314
- async function handlePolicyCheck(deps) {
15575
+ // Atomic write: write to a temp file in the same directory, then rename.
15576
+ // rename() replaces the destination directory entry atomically — if the
15577
+ // target path is (or becomes) a symlink, rename replaces the symlink
15578
+ // itself rather than following it, closing the TOCTOU window between
15579
+ // validation and write.
15580
+ // Uses crypto.randomBytes for an unpredictable suffix and exclusive-create
15581
+ // flag ('wx' = O_CREAT | O_WRONLY | O_EXCL) so writeFile fails if a
15582
+ // symlink or file already exists at the temp path.
15583
+ const tmpSuffix = (0,external_node_crypto_namespaceObject.randomBytes)(8).toString("hex");
15584
+ const tmpPath = `${filePath}.${tmpSuffix}.tmp`;
15315
15585
  try {
15316
- const rawStdin = await deps.readStdin();
15317
- let input;
15586
+ await deps.writeFile(tmpPath, content, {
15587
+ flag: "wx"
15588
+ });
15589
+ await deps.rename(tmpPath, filePath);
15590
+ } catch (err) {
15591
+ // Best-effort cleanup of orphaned temp file on rename failure
15318
15592
  try {
15319
- input = JSON.parse(rawStdin);
15593
+ await deps.unlink(tmpPath);
15320
15594
  } catch {
15321
- deps.writeStderr("agent-shell: failed to parse stdin as JSON\n");
15322
- deps.writeStdout(denyResponse("Invalid JSON input"));
15323
- return;
15595
+ // Ignore cleanup errors the temp file may not exist if
15596
+ // writeFile failed, or the directory may be read-only.
15324
15597
  }
15325
- const hookResult = HookInputSchema.safeParse(input);
15326
- if (!hookResult.success) {
15327
- deps.writeStdout(denyResponse("Missing or invalid toolName field"));
15328
- return;
15598
+ throw err;
15599
+ }
15600
+ deps.writeStdout(`Wrote ${subpath}\n`);
15601
+ return true;
15602
+ }
15603
+ async function handleInit(flags, deps) {
15604
+ const repoRoot = deps.getRepositoryRoot();
15605
+ const hooksPath = (0,external_node_path_namespaceObject.join)(repoRoot, init_command_HOOKS_SUBPATH);
15606
+ const { config: existingConfig, error: loadError } = await loadExistingHooksConfig(hooksPath, deps);
15607
+ if (loadError) {
15608
+ return false;
15609
+ }
15610
+ const existing = existingConfig ? detectExistingFeatures(existingConfig) : {
15611
+ hasPreToolUse: false,
15612
+ hasPostToolUse: false
15613
+ };
15614
+ if (existing.hasPreToolUse && existing.hasPostToolUse) {
15615
+ deps.writeStdout("All features already configured in hooks.json\n");
15616
+ return true;
15617
+ }
15618
+ let enableFlightRecorder;
15619
+ let enablePolicy;
15620
+ if (hasExplicitFlags(flags)) {
15621
+ // Non-interactive: apply explicit flags only — don't add unspecified features
15622
+ const selections = resolveExplicitFlagSelections(flags);
15623
+ enableFlightRecorder = selections.enableFlightRecorder && !existing.hasPostToolUse;
15624
+ enablePolicy = selections.enablePolicy && !existing.hasPreToolUse;
15625
+ // If the user requested features but they're all already configured, report no-op
15626
+ if (!enableFlightRecorder && !enablePolicy && (selections.enableFlightRecorder || selections.enablePolicy)) {
15627
+ deps.writeStdout("Requested features already configured in hooks.json; nothing to do\n");
15628
+ return true;
15329
15629
  }
15330
- const { toolName, toolArgs } = hookResult.data;
15331
- // Step 3 (per spec): load and validate policy before terminal tool check
15332
- // (fail-closed on invalid policy, even for non-terminal tools)
15333
- let policy;
15334
- try {
15335
- policy = await loadPolicy(deps.env, deps.policyDeps);
15336
- } catch (err) {
15337
- deps.writeStderr(`agent-shell: policy load error: ${sanitizeForStderr(err)}\n`);
15338
- deps.writeStdout(denyResponse("Failed to load policy"));
15339
- return;
15630
+ } else if (!deps.isTty && deps.prompt === undefined) {
15631
+ // Non-TTY with no explicit flags: default to enabling all missing features
15632
+ const missing = [];
15633
+ enableFlightRecorder = !existing.hasPostToolUse;
15634
+ enablePolicy = !existing.hasPreToolUse;
15635
+ if (enableFlightRecorder) missing.push("flight recording");
15636
+ if (enablePolicy) missing.push("policy blocking");
15637
+ if (missing.length > 0) {
15638
+ deps.writeStderr(`agent-shell: non-interactive mode, auto-enabling: ${missing.join(", ")}\n`);
15639
+ }
15640
+ } else if (deps.prompt) {
15641
+ // Interactive: prompt for each missing feature
15642
+ enableFlightRecorder = false;
15643
+ enablePolicy = false;
15644
+ if (!existing.hasPostToolUse) {
15645
+ enableFlightRecorder = await deps.prompt("Enable flight recording to capture all agent tool usage?");
15646
+ }
15647
+ if (!existing.hasPreToolUse) {
15648
+ enablePolicy = await deps.prompt("Enable policy-based command blocking?");
15340
15649
  }
15341
- // Step 4 (per spec): non-terminal tools pass through with allow.
15342
- // Per spec: command field is empty string for non-terminal tool decisions
15343
- // (toolArgs is never parsed, so no command string is available).
15344
- if (!TERMINAL_TOOLS.has(toolName)) {
15345
- deps.writeStdout(allowResponse());
15346
- await tryEmitTelemetry(deps, "", "allow", null);
15347
- return;
15650
+ } else {
15651
+ // Fallback: enable all missing features
15652
+ enableFlightRecorder = !existing.hasPostToolUse;
15653
+ enablePolicy = !existing.hasPreToolUse;
15654
+ }
15655
+ if (!enableFlightRecorder && !enablePolicy) {
15656
+ deps.writeStdout("No features selected, nothing to do.\n");
15657
+ return true;
15658
+ }
15659
+ // Build the hooks config by merging agent-shell hooks into existing config,
15660
+ // preserving any other hooks (sessionStart, sessionEnd, userPromptSubmitted, etc.)
15661
+ const mergedConfig = existingConfig ? structuredClone(existingConfig) : {
15662
+ version: 1,
15663
+ hooks: {}
15664
+ };
15665
+ if (enablePolicy && !existing.hasPreToolUse) {
15666
+ const policyHook = {
15667
+ type: "command",
15668
+ bash: AGENT_SHELL_POLICY_CHECK,
15669
+ timeoutSec: 30
15670
+ };
15671
+ mergedConfig.hooks.preToolUse = [
15672
+ ...mergedConfig.hooks.preToolUse ?? [],
15673
+ policyHook
15674
+ ];
15675
+ }
15676
+ if (enableFlightRecorder && !existing.hasPostToolUse) {
15677
+ const recordHook = {
15678
+ type: "command",
15679
+ bash: AGENT_SHELL_RECORD,
15680
+ timeoutSec: 30
15681
+ };
15682
+ mergedConfig.hooks.postToolUse = [
15683
+ ...mergedConfig.hooks.postToolUse ?? [],
15684
+ recordHook
15685
+ ];
15686
+ }
15687
+ const hooksContent = `${JSON.stringify(mergedConfig, null, 2)}\n`;
15688
+ const hooksWritten = await writeConfigFile(repoRoot, init_command_HOOKS_SUBPATH, hooksContent, deps);
15689
+ if (!hooksWritten) {
15690
+ return false;
15691
+ }
15692
+ // Generate policy.json if policy is being enabled and no policy file exists yet.
15693
+ // If the file already exists, ensure agent-shell's own commands are in the allow list
15694
+ // so that the preToolUse hook doesn't block the sibling postToolUse hook.
15695
+ if (enablePolicy) {
15696
+ const policyPath = (0,external_node_path_namespaceObject.join)(repoRoot, init_command_POLICY_SUBPATH);
15697
+ let existingContent = null;
15698
+ try {
15699
+ existingContent = await deps.readFile(policyPath, "utf-8");
15700
+ } catch (error) {
15701
+ if (!isPathNotFoundError(error)) {
15702
+ throw error;
15703
+ }
15704
+ }
15705
+ if (existingContent === null) {
15706
+ deps.writeStdout("Scanning project...\n");
15707
+ const scanResult = await deps.scanProject(repoRoot);
15708
+ const policy = generatePolicy(scanResult);
15709
+ const policyContent = `${JSON.stringify(policy, null, 2)}\n`;
15710
+ const policyWritten = await writeConfigFile(repoRoot, init_command_POLICY_SUBPATH, policyContent, deps);
15711
+ if (!policyWritten) {
15712
+ return false;
15713
+ }
15714
+ } else {
15715
+ // Ensure agent-shell commands are in the existing allow list
15716
+ const patchResult = ensureAgentShellAllowed(existingContent);
15717
+ switch(patchResult.status){
15718
+ case "patched":
15719
+ {
15720
+ const policyWritten = await writeConfigFile(repoRoot, init_command_POLICY_SUBPATH, patchResult.content, deps);
15721
+ if (!policyWritten) {
15722
+ return false;
15723
+ }
15724
+ break;
15725
+ }
15726
+ case "unchanged":
15727
+ deps.writeStdout("Policy already exists with agent-shell rules; skipping policy.json generation.\n");
15728
+ break;
15729
+ case "invalid":
15730
+ deps.writeStderr(`agent-shell: existing policy.json is invalid (${sanitizeForStderr(patchResult.reason)}), regenerating\n`);
15731
+ deps.writeStdout("Scanning project...\n");
15732
+ {
15733
+ const scanResult = await deps.scanProject(repoRoot);
15734
+ const policy = generatePolicy(scanResult);
15735
+ const policyContent = `${JSON.stringify(policy, null, 2)}\n`;
15736
+ const policyWritten = await writeConfigFile(repoRoot, init_command_POLICY_SUBPATH, policyContent, deps);
15737
+ if (!policyWritten) {
15738
+ return false;
15739
+ }
15740
+ }
15741
+ break;
15742
+ default:
15743
+ {
15744
+ const _exhaustive = patchResult;
15745
+ throw new Error(`Unhandled policy patch status: ${_exhaustive.status}`);
15746
+ }
15747
+ }
15348
15748
  }
15349
- // Terminal tool — validate toolArgs
15350
- if (typeof toolArgs !== "string") {
15351
- deps.writeStdout(denyResponse("Missing or non-string toolArgs for terminal tool"));
15352
- return;
15749
+ }
15750
+ // Print summary
15751
+ const actions = [];
15752
+ if (enableFlightRecorder) actions.push("flight recording");
15753
+ if (enablePolicy) actions.push("policy blocking");
15754
+ deps.writeStdout(`\nEnabled: ${actions.join(", ")}\n`);
15755
+ return true;
15756
+ }
15757
+
15758
+ ;// CONCATENATED MODULE: ./src/log/format.ts
15759
+
15760
+ function formatDuration(ms) {
15761
+ if (ms < 1000) return `${Math.round(ms)}ms`;
15762
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
15763
+ return `${(ms / 60000).toFixed(1)}m`;
15764
+ }
15765
+ function formatTimestamp(iso) {
15766
+ const d = new Date(iso);
15767
+ return d.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
15768
+ }
15769
+ function truncateCommand(command, maxLen) {
15770
+ if (command.length <= maxLen) return command;
15771
+ return `${command.slice(0, maxLen - 3)}...`;
15772
+ }
15773
+ function padRight(str, width) {
15774
+ if (str.length >= width) return str;
15775
+ return str + " ".repeat(width - str.length);
15776
+ }
15777
+ const COL_TIMESTAMP = 21;
15778
+ const COL_SCRIPT = 9;
15779
+ const COL_ACTOR = 13;
15780
+ const COL_EXIT = 6;
15781
+ const COL_DURATION = 10;
15782
+ const MAX_COMMAND_LEN = 50;
15783
+ function formatEventsTable(events) {
15784
+ const header = padRight("TIMESTAMP", COL_TIMESTAMP) + padRight("SCRIPT", COL_SCRIPT) + padRight("ACTOR", COL_ACTOR) + padRight("EXIT", COL_EXIT) + padRight("DURATION", COL_DURATION) + "COMMAND";
15785
+ const rows = events.map((event)=>{
15786
+ const timestamp = formatTimestamp(event.timestamp);
15787
+ const script = event.event === "script_end" ? event.script ?? "-" : event.event === "tool_use" ? sanitizeOutput(event.tool_name) : "-";
15788
+ const actor = event.actor;
15789
+ const exitCode = event.event === "script_end" ? String(event.exit_code) : "-";
15790
+ const duration = event.event === "script_end" ? formatDuration(event.duration_ms) : "-";
15791
+ const command = truncateCommand(event.command, MAX_COMMAND_LEN);
15792
+ return padRight(timestamp, COL_TIMESTAMP) + padRight(script, COL_SCRIPT) + padRight(actor, COL_ACTOR) + padRight(exitCode, COL_EXIT) + padRight(duration, COL_DURATION) + command;
15793
+ });
15794
+ return [
15795
+ header,
15796
+ ...rows
15797
+ ].join("\n");
15798
+ }
15799
+ function formatSessionsTable(sessions) {
15800
+ const colSession = 11;
15801
+ const colFirstEvent = 21;
15802
+ const colLastEvent = 21;
15803
+ const colEvents = 9;
15804
+ const header = padRight("SESSION", colSession) + padRight("FIRST EVENT", colFirstEvent) + padRight("LAST EVENT", colLastEvent) + padRight("EVENTS", colEvents) + "ACTORS";
15805
+ const rows = sessions.map((s)=>{
15806
+ const sessionTrunc = s.sessionId.slice(0, 8);
15807
+ const firstEvent = formatTimestamp(s.firstEvent);
15808
+ const lastEvent = formatTimestamp(s.lastEvent);
15809
+ const eventCount = String(s.eventCount);
15810
+ const actors = s.actors.join(", ");
15811
+ return padRight(sessionTrunc, colSession) + padRight(firstEvent, colFirstEvent) + padRight(lastEvent, colLastEvent) + padRight(eventCount, colEvents) + actors;
15812
+ });
15813
+ return [
15814
+ header,
15815
+ ...rows
15816
+ ].join("\n");
15817
+ }
15818
+ function formatEventsJson(events) {
15819
+ return JSON.stringify(events, null, 2);
15820
+ }
15821
+
15822
+ ;// CONCATENATED MODULE: ./src/log/query.ts
15823
+ // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
15824
+
15825
+
15826
+
15827
+ const DURATION_PATTERN = /^(\d+)([mhd])$/;
15828
+ const MAX_LINE_BYTES = 65_536;
15829
+ const MAX_LINES_PER_FILE = 100_000;
15830
+ const UNIT_MS = {
15831
+ m: 60 * 1000,
15832
+ h: 60 * 60 * 1000,
15833
+ d: 24 * 60 * 60 * 1000
15834
+ };
15835
+ function parseDuration(duration) {
15836
+ const match = DURATION_PATTERN.exec(duration);
15837
+ if (!match) {
15838
+ throw new Error(`Invalid duration format: "${duration}". Expected format: <number><unit> where unit is m, h, or d`);
15839
+ }
15840
+ const value = Number.parseInt(match[1], 10);
15841
+ if (value <= 0) {
15842
+ throw new Error(`Duration must be a positive value, got: "${duration}"`);
15843
+ }
15844
+ const unit = match[2];
15845
+ return value * UNIT_MS[unit];
15846
+ }
15847
+ async function resolveReadEventsDir(env, deps) {
15848
+ const projectRoot = deps.cwd();
15849
+ const defaultDir = (0,external_node_path_namespaceObject.join)(projectRoot, ".agent-shell", "events");
15850
+ const logDir = env.AGENTSHELL_LOG_DIR;
15851
+ if (logDir !== undefined && logDir !== "") {
15852
+ const projectRootReal = await deps.realpath(projectRoot);
15853
+ const candidate = (0,external_node_path_namespaceObject.resolve)(projectRoot, logDir);
15854
+ if (!isWithinProjectRoot(candidate, projectRoot) && !isWithinProjectRoot(candidate, projectRootReal)) {
15855
+ return {
15856
+ dir: "",
15857
+ error: "AGENTSHELL_LOG_DIR resolves outside project root"
15858
+ };
15353
15859
  }
15354
- let parsedArgs;
15860
+ let resolved;
15355
15861
  try {
15356
- parsedArgs = JSON.parse(toolArgs);
15357
- } catch {
15358
- deps.writeStderr("agent-shell: failed to parse toolArgs as JSON\n");
15359
- deps.writeStdout(denyResponse("Invalid JSON in toolArgs"));
15360
- return;
15361
- }
15362
- // toolArgs must be a non-null plain object
15363
- if (parsedArgs === null || typeof parsedArgs !== "object" || Array.isArray(parsedArgs)) {
15364
- deps.writeStdout(denyResponse("toolArgs must be a non-null plain object for terminal tools"));
15365
- return;
15366
- }
15367
- const obj = parsedArgs;
15368
- if (!Object.hasOwn(obj, "command")) {
15369
- deps.writeStdout(denyResponse("Missing command field in toolArgs"));
15370
- return;
15862
+ resolved = await deps.realpath(candidate);
15863
+ } catch (err) {
15864
+ if (isPathNotFoundError(err)) {
15865
+ return {
15866
+ dir: "",
15867
+ error: "AGENTSHELL_LOG_DIR does not exist or is not a directory"
15868
+ };
15869
+ }
15870
+ throw err;
15371
15871
  }
15372
- if (typeof obj.command !== "string") {
15373
- deps.writeStdout(denyResponse("command field must be a string"));
15374
- return;
15872
+ if (!isWithinProjectRoot(resolved, projectRootReal)) {
15873
+ return {
15874
+ dir: "",
15875
+ error: "AGENTSHELL_LOG_DIR resolves outside project root"
15876
+ };
15375
15877
  }
15376
- const command = obj.command;
15377
- const result = evaluatePolicy(policy, command);
15378
- const responseJson = result.decision === "allow" ? allowResponse() : denyResponse(`Command '${command}' denied by policy rule: ${result.matchedRule ?? "not in allow list"}`);
15379
- deps.writeStdout(responseJson);
15380
- await tryEmitTelemetry(deps, command.trim(), result.decision, result.matchedRule);
15381
- } catch (err) {
15382
- deps.writeStderr(`agent-shell: unexpected error: ${sanitizeForStderr(err)}\n`);
15383
- deps.writeStdout(denyResponse("Internal error during policy evaluation"));
15878
+ return {
15879
+ dir: resolved
15880
+ };
15384
15881
  }
15882
+ return {
15883
+ dir: defaultDir
15884
+ };
15385
15885
  }
15386
-
15387
- ;// CONCATENATED MODULE: ./src/copilot-prompt.ts
15388
-
15389
- function formatScriptsSummary(scripts) {
15390
- return scripts.map((s)=>` - \`${sanitizePromptValue(s.name)}\`: \`${sanitizePromptValue(s.command)}\``).join("\n");
15391
- }
15392
- function formatWorkflowSummary(commands) {
15393
- return commands.map((cmd)=>` - \`${sanitizePromptValue(cmd)}\``).join("\n");
15886
+ function findMostRecentFile(fileMtimes) {
15887
+ let mostRecent = fileMtimes[0];
15888
+ for(let i = 1; i < fileMtimes.length; i++){
15889
+ if (fileMtimes[i].mtimeMs > mostRecent.mtimeMs) {
15890
+ mostRecent = fileMtimes[i];
15891
+ }
15892
+ }
15893
+ return mostRecent.file;
15394
15894
  }
15395
- function formatMiseTasksSummary(tasks) {
15396
- return tasks.map((t)=>` - \`${sanitizePromptValue(t.name)}\`: \`${sanitizePromptValue(t.command)}\``).join("\n");
15895
+ function matchesFilters(event, filters, cutoffMs) {
15896
+ if (filters.actor !== undefined && event.actor !== filters.actor) {
15897
+ return false;
15898
+ }
15899
+ if (filters.failures && !(event.event === "script_end" && event.exit_code !== 0)) {
15900
+ return false;
15901
+ }
15902
+ if (filters.script !== undefined && !(event.event === "script_end" && event.script === filters.script)) {
15903
+ return false;
15904
+ }
15905
+ if (cutoffMs !== undefined) {
15906
+ const eventMs = new Date(event.timestamp).getTime();
15907
+ if (eventMs < cutoffMs) {
15908
+ return false;
15909
+ }
15910
+ }
15911
+ return true;
15397
15912
  }
15398
- /**
15399
- * Builds the system message that establishes persistent behavioral
15400
- * constraints for the Copilot SDK session. This includes security goals,
15401
- * tool descriptions, and response format requirements — context that
15402
- * must be respected across all tool interactions during the session.
15403
- */ function buildSystemMessage() {
15404
- return `You are a security-focused policy analyst for agent-shell — a security layer that intercepts terminal commands executed by AI coding agents. Your goal is to generate a minimal, secure allow list for a \`policy.json\` file.
15405
-
15406
- ## Security Principles
15407
-
15408
- - Only commands genuinely needed for development workflows should be permitted
15409
- - Overly broad rules create security risks (e.g. an agent could chain \`npm test && curl evil.com\`)
15410
- - Use exact commands — avoid wildcards unless the command is genuinely read-only (e.g. \`git status *\`)
15411
- - Always validate proposed commands with \`validate_allow_rule\` before including them
15412
- - Commands containing shell metacharacters (\`;\`, \`|\`, \`&\`, \`\`\`, \`$\`, etc.) are never safe
15413
-
15414
- ## Available Tools
15415
-
15416
- ### MCP Tools (lousy-agents server)
15417
-
15418
- - **discover_feedback_loops**: Discover npm scripts and CLI tools from workflows, mapped to SDLC phases (test, build, lint, format, security). Returns structured results grouped by phase. **Start here.**
15419
- - **discover_environment**: Discover environment config files (mise.toml, .nvmrc, .python-version, etc.) and detect package managers.
15420
-
15421
- ### Custom Tools
15422
-
15423
- - **read_project_file**: Read any file in the repository (truncated at 100KB). Use to inspect configs discovered via MCP tools.
15424
- - **validate_allow_rule**: Check whether a proposed command is safe for the allow list (rejects commands containing shell metacharacters).
15425
-
15426
- ## Response Format
15427
-
15428
- After exploring, respond with **only** a JSON object matching this exact schema — no markdown fences, no explanation:
15429
-
15430
- \`\`\`json
15431
- {
15432
- "additionalAllowRules": ["<command>", ...],
15433
- "suggestions": ["<human-readable suggestion>", ...]
15913
+ function parseLine(line) {
15914
+ if (Buffer.byteLength(line, "utf-8") > MAX_LINE_BYTES) {
15915
+ return undefined;
15916
+ }
15917
+ let parsed;
15918
+ try {
15919
+ parsed = JSON.parse(line);
15920
+ } catch {
15921
+ return undefined;
15922
+ }
15923
+ const result = ScriptEventSchema.safeParse(parsed);
15924
+ if (!result.success) {
15925
+ return undefined;
15926
+ }
15927
+ return result.data;
15434
15928
  }
15435
- \`\`\`
15436
-
15437
- - \`additionalAllowRules\`: string array of specific commands to add to the allow list
15438
- - \`suggestions\`: string array of human-readable observations or recommendations about the policy`;
15929
+ async function queryEvents(eventsDir, filters, deps) {
15930
+ const allFiles = await deps.readdir(eventsDir);
15931
+ const jsonlFiles = allFiles.filter((f)=>f.endsWith(".jsonl"));
15932
+ if (jsonlFiles.length === 0) {
15933
+ return {
15934
+ events: [],
15935
+ truncatedFiles: []
15936
+ };
15937
+ }
15938
+ let filesToRead;
15939
+ if (filters.last === undefined) {
15940
+ const fileMtimes = await Promise.all(jsonlFiles.map(async (file)=>{
15941
+ const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, file);
15942
+ const s = await deps.stat(filePath);
15943
+ return {
15944
+ file,
15945
+ mtimeMs: s.mtimeMs
15946
+ };
15947
+ }));
15948
+ filesToRead = [
15949
+ findMostRecentFile(fileMtimes)
15950
+ ];
15951
+ } else {
15952
+ filesToRead = jsonlFiles;
15953
+ }
15954
+ const cutoffMs = filters.last ? Date.now() - parseDuration(filters.last) : undefined;
15955
+ const events = [];
15956
+ const truncatedFiles = [];
15957
+ for (const file of filesToRead){
15958
+ const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, file);
15959
+ let lineCount = 0;
15960
+ for await (const line of deps.readFileLines(filePath)){
15961
+ if (lineCount >= MAX_LINES_PER_FILE) {
15962
+ deps.writeStderr(`agent-shell: file ${file} exceeds ${MAX_LINES_PER_FILE} lines, truncating\n`);
15963
+ truncatedFiles.push(file);
15964
+ break;
15965
+ }
15966
+ lineCount++;
15967
+ const event = parseLine(line);
15968
+ if (event === undefined) {
15969
+ continue;
15970
+ }
15971
+ if (matchesFilters(event, filters, cutoffMs)) {
15972
+ events.push(event);
15973
+ }
15974
+ }
15975
+ }
15976
+ return {
15977
+ events,
15978
+ truncatedFiles
15979
+ };
15980
+ }
15981
+ async function listSessions(eventsDir, deps) {
15982
+ const allFiles = await deps.readdir(eventsDir);
15983
+ const jsonlFiles = allFiles.filter((f)=>f.endsWith(".jsonl"));
15984
+ if (jsonlFiles.length === 0) {
15985
+ return [];
15986
+ }
15987
+ const summaries = [];
15988
+ for (const file of jsonlFiles){
15989
+ const sessionId = file.replace(/\.jsonl$/, "");
15990
+ const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, file);
15991
+ let firstEvent;
15992
+ let lastEvent;
15993
+ let eventCount = 0;
15994
+ const actorSet = new Set();
15995
+ for await (const line of deps.readFileLines(filePath)){
15996
+ let parsed;
15997
+ try {
15998
+ parsed = JSON.parse(line);
15999
+ } catch {
16000
+ continue;
16001
+ }
16002
+ const result = ScriptEventSchema.safeParse(parsed);
16003
+ if (!result.success) {
16004
+ continue;
16005
+ }
16006
+ const event = result.data;
16007
+ eventCount++;
16008
+ actorSet.add(event.actor);
16009
+ if (firstEvent === undefined || event.timestamp < firstEvent) {
16010
+ firstEvent = event.timestamp;
16011
+ }
16012
+ if (lastEvent === undefined || event.timestamp > lastEvent) {
16013
+ lastEvent = event.timestamp;
16014
+ }
16015
+ }
16016
+ if (eventCount > 0 && firstEvent !== undefined && lastEvent !== undefined) {
16017
+ summaries.push({
16018
+ sessionId,
16019
+ firstEvent,
16020
+ lastEvent,
16021
+ eventCount,
16022
+ actors: [
16023
+ ...actorSet
16024
+ ]
16025
+ });
16026
+ }
16027
+ }
16028
+ summaries.sort((a, b)=>b.lastEvent.localeCompare(a.lastEvent));
16029
+ return summaries;
15439
16030
  }
15440
- /**
15441
- * Builds the user prompt containing project-specific scan results
15442
- * and Socratic questions to guide exploration.
15443
- */ function buildAnalysisPrompt(scanResult, repoRoot) {
15444
- const scriptsList = scanResult.scripts.length > 0 ? formatScriptsSummary(scanResult.scripts) : " (none found)";
15445
- const workflowList = scanResult.workflowCommands.length > 0 ? formatWorkflowSummary(scanResult.workflowCommands) : " (none found)";
15446
- const miseList = scanResult.miseTasks.length > 0 ? formatMiseTasksSummary(scanResult.miseTasks) : " (none found)";
15447
- const languagesList = scanResult.languages.length > 0 ? scanResult.languages.join(", ") : "(none detected)";
15448
- const safeRepoRoot = sanitizePromptValue(repoRoot);
15449
- return `# Project Analysis Request
15450
-
15451
- ## Static Analysis Results
15452
-
15453
- We have already discovered the following from static file analysis:
15454
16031
 
15455
- - **Repository root**: \`${safeRepoRoot}\`
15456
- - **Detected languages**: ${languagesList}
16032
+ ;// CONCATENATED MODULE: ./src/log/index.ts
15457
16033
 
15458
- ### npm scripts (from package.json)
15459
- ${scriptsList}
15460
16034
 
15461
- ### GitHub Actions workflow commands
15462
- ${workflowList}
15463
16035
 
15464
- ### Mise tasks (from mise.toml)
15465
- ${miseList}
15466
16036
 
15467
- ## Questions to Explore
15468
16037
 
15469
- 1. Call \`discover_feedback_loops\` to get a structured view of project commands mapped to SDLC phases. Are there commands the static analysis missed?
15470
- 2. Call \`discover_environment\` to understand the runtime setup. What toolchain commands does the environment require?
15471
- 3. Given the detected languages (${languagesList}), what language-specific toolchain commands (e.g. \`cargo test\`, \`go build\`, \`pip install\`) might be needed but aren't captured above?
15472
- 4. Are there any commands in the discovered lists that look suspicious or overly broad for a development workflow?
15473
- 5. Use \`validate_allow_rule\` to verify each proposed command before including it in your response.`;
16038
+ function parseLogArgs(args) {
16039
+ const options = {
16040
+ failures: false,
16041
+ listSessions: false,
16042
+ json: false
16043
+ };
16044
+ let i = 0;
16045
+ while(i < args.length){
16046
+ const arg = args[i];
16047
+ switch(arg){
16048
+ case "--last":
16049
+ if (i + 1 < args.length) {
16050
+ options.last = args[++i];
16051
+ } else {
16052
+ options.errors = options.errors ?? [];
16053
+ options.errors.push("--last requires a value (e.g., 30m, 1h, 1d)");
16054
+ }
16055
+ break;
16056
+ case "--actor":
16057
+ if (i + 1 < args.length) {
16058
+ options.actor = args[++i];
16059
+ } else {
16060
+ options.errors = options.errors ?? [];
16061
+ options.errors.push("--actor requires a value");
16062
+ }
16063
+ break;
16064
+ case "--failures":
16065
+ options.failures = true;
16066
+ break;
16067
+ case "--script":
16068
+ if (i + 1 < args.length) {
16069
+ options.script = args[++i];
16070
+ } else {
16071
+ options.errors = options.errors ?? [];
16072
+ options.errors.push("--script requires a value");
16073
+ }
16074
+ break;
16075
+ case "--list-sessions":
16076
+ options.listSessions = true;
16077
+ break;
16078
+ case "--json":
16079
+ options.json = true;
16080
+ break;
16081
+ }
16082
+ i++;
16083
+ }
16084
+ return options;
15474
16085
  }
15475
-
15476
- ;// CONCATENATED MODULE: external "node:module"
15477
- const external_node_module_namespaceObject = __rspack_createRequire_require("node:module");
15478
- ;// CONCATENATED MODULE: external "node:url"
15479
- const external_node_url_namespaceObject = __rspack_createRequire_require("node:url");
15480
- ;// CONCATENATED MODULE: ./src/resolve-sdk.ts
15481
-
15482
-
15483
-
15484
-
15485
- /**
15486
- * Resolves an npm package's ESM entry point from the user's project directory.
15487
- *
15488
- * Bundled CLIs resolve bare specifiers relative to the bundle location, not
15489
- * the user's working directory. This uses `createRequire` anchored at the
15490
- * project root to locate the package, then reads its `package.json` exports
15491
- * map to select the ESM entry that `import()` would use.
15492
- *
15493
- * @returns A file:// URL to the package ESM entry point, or null if not found
15494
- */ function resolveSdkPath(repoRoot, packageName) {
15495
- if (!repoRoot || !(0,external_node_path_namespaceObject.isAbsolute)(repoRoot)) return null;
15496
- if (/^[./]/.test(packageName) || packageName.includes("..")) return null;
16086
+ function createDefaultQueryDeps() {
16087
+ return {
16088
+ readdir: (path)=>(0,promises_namespaceObject.readdir)(path),
16089
+ stat: (path)=>(0,promises_namespaceObject.stat)(path).then((s)=>({
16090
+ mtimeMs: s.mtimeMs
16091
+ })),
16092
+ realpath: (path)=>(0,promises_namespaceObject.realpath)(path),
16093
+ cwd: ()=>process.cwd(),
16094
+ readFileLines: (path)=>(0,external_node_readline_namespaceObject.createInterface)({
16095
+ input: (0,external_node_fs_namespaceObject.createReadStream)(path, {
16096
+ encoding: "utf-8"
16097
+ })
16098
+ }),
16099
+ writeStderr: (msg)=>{
16100
+ process.stderr.write(msg);
16101
+ }
16102
+ };
16103
+ }
16104
+ async function runLog(args) {
16105
+ const options = parseLogArgs(args);
16106
+ const deps = createDefaultQueryDeps();
16107
+ if (options.errors && options.errors.length > 0) {
16108
+ for (const err of options.errors){
16109
+ process.stderr.write(`agent-shell: ${err}\n`);
16110
+ }
16111
+ return 1;
16112
+ }
16113
+ const { dir, error } = await resolveReadEventsDir(process.env, deps);
16114
+ if (error) {
16115
+ process.stderr.write(`agent-shell: ${error}\n`);
16116
+ return 1;
16117
+ }
16118
+ if (options.last) {
16119
+ try {
16120
+ parseDuration(options.last);
16121
+ } catch (err) {
16122
+ process.stderr.write(`agent-shell: ${err.message}\n`);
16123
+ return 1;
16124
+ }
16125
+ }
15497
16126
  try {
15498
- const projectRequire = (0,external_node_module_namespaceObject.createRequire)((0,external_node_path_namespaceObject.resolve)(repoRoot, "package.json"));
15499
- const cjsResolved = projectRequire.resolve(packageName);
15500
- const esmUrl = findEsmEntry(cjsResolved, packageName);
15501
- return esmUrl ?? (0,external_node_url_namespaceObject.pathToFileURL)(cjsResolved).href;
16127
+ await deps.readdir(dir);
15502
16128
  } catch {
15503
- return null;
16129
+ process.stdout.write("No events recorded yet.\n\n");
16130
+ process.stdout.write("To enable instrumentation, add to your .npmrc:\n");
16131
+ process.stdout.write(" script-shell=./node_modules/.bin/agent-shell\n");
16132
+ return 0;
16133
+ }
16134
+ if (options.listSessions) {
16135
+ const sessions = await listSessions(dir, deps);
16136
+ if (sessions.length === 0) {
16137
+ process.stdout.write("No sessions found.\n");
16138
+ return 0;
16139
+ }
16140
+ process.stdout.write(formatSessionsTable(sessions));
16141
+ process.stdout.write("\n");
16142
+ return 0;
16143
+ }
16144
+ const result = await queryEvents(dir, {
16145
+ actor: options.actor,
16146
+ failures: options.failures || undefined,
16147
+ script: options.script,
16148
+ last: options.last
16149
+ }, deps);
16150
+ if (result.events.length === 0) {
16151
+ process.stdout.write("No matching events found.\n");
16152
+ return 0;
16153
+ }
16154
+ if (options.json) {
16155
+ process.stdout.write(formatEventsJson(result.events));
16156
+ process.stdout.write("\n");
16157
+ } else {
16158
+ process.stdout.write(formatEventsTable(result.events));
16159
+ process.stdout.write("\n");
15504
16160
  }
16161
+ return 0;
15505
16162
  }
15506
- /**
15507
- * Walks up from a resolved module path to find the package root directory,
15508
- * then extracts the ESM entry point from its exports map.
15509
- */ function findEsmEntry(resolvedPath, packageName) {
15510
- let dir = (0,external_node_path_namespaceObject.dirname)(resolvedPath);
15511
- while(true){
15512
- try {
15513
- const raw = (0,external_node_fs_namespaceObject.readFileSync)((0,external_node_path_namespaceObject.join)(dir, "package.json"), "utf-8");
15514
- const pkg = JSON.parse(raw);
15515
- if (typeof pkg === "object" && pkg !== null && pkg.name === packageName) {
15516
- const esmEntry = extractEsmEntry(pkg);
15517
- const full = (0,external_node_path_namespaceObject.resolve)(dir, esmEntry);
15518
- try {
15519
- return (0,external_node_url_namespaceObject.pathToFileURL)((0,external_node_fs_namespaceObject.realpathSync)(full)).href;
15520
- } catch {
15521
- return null;
15522
- }
16163
+
16164
+ ;// CONCATENATED MODULE: ./src/mode.ts
16165
+ const MODEL_PATTERN = /^[a-zA-Z0-9._-]+$/;
16166
+ function parsePolicyInitOptions(args) {
16167
+ const options = {};
16168
+ for (const arg of args.slice(2)){
16169
+ if (arg.startsWith("--model=")) {
16170
+ const value = arg.slice("--model=".length);
16171
+ if (value.length > 0 && value.length <= 128 && MODEL_PATTERN.test(value)) {
16172
+ options.model = value;
15523
16173
  }
15524
- } catch {
15525
- /* no package.json at this level */ }
15526
- const parent = (0,external_node_path_namespaceObject.dirname)(dir);
15527
- if (parent === dir) return null;
15528
- dir = parent;
16174
+ }
15529
16175
  }
16176
+ return options;
15530
16177
  }
15531
- /**
15532
- * Extracts the ESM entry point from a parsed package.json object.
15533
- */ function extractEsmEntry(pkgJson) {
15534
- if (typeof pkgJson === "object" && pkgJson !== null) {
15535
- const pkg = pkgJson;
15536
- const esmPath = resolveExportsImportEntry(pkg.exports);
15537
- if (esmPath) return esmPath;
15538
- if (typeof pkg.main === "string" && pkg.main.length > 0) return pkg.main;
16178
+ function parseInitOptions(args) {
16179
+ const options = {
16180
+ flightRecorder: false,
16181
+ policy: false,
16182
+ noFlightRecorder: false,
16183
+ noPolicy: false,
16184
+ unknownArgs: []
16185
+ };
16186
+ for (const arg of args.slice(1)){
16187
+ switch(arg){
16188
+ case "--flight-recorder":
16189
+ options.flightRecorder = true;
16190
+ break;
16191
+ case "--policy":
16192
+ options.policy = true;
16193
+ break;
16194
+ case "--no-flight-recorder":
16195
+ options.noFlightRecorder = true;
16196
+ break;
16197
+ case "--no-policy":
16198
+ options.noPolicy = true;
16199
+ break;
16200
+ default:
16201
+ options.unknownArgs.push(arg);
16202
+ break;
16203
+ }
15539
16204
  }
15540
- return "index.js";
16205
+ return options;
15541
16206
  }
15542
- /**
15543
- * Navigates the exports map to find the ESM entry. Supports string-shaped
15544
- * exports, string-valued `exports["."]`, `exports["."].import` (string),
15545
- * `exports["."].import.default`, and the sugar form where condition keys
15546
- * (`import`/`require`) appear directly on the exports object.
15547
- */ function resolveExportsImportEntry(exports) {
15548
- if (typeof exports === "string") return exports;
15549
- if (typeof exports !== "object" || exports === null) return null;
15550
- const exportsMap = exports;
15551
- const dotEntry = exportsMap["."];
15552
- if (typeof dotEntry === "string") return dotEntry;
15553
- const conditionSource = typeof dotEntry === "object" && dotEntry !== null && !Array.isArray(dotEntry) ? dotEntry : exportsMap;
15554
- const importEntry = conditionSource.import;
15555
- if (typeof importEntry === "string") return importEntry;
15556
- if (typeof importEntry === "object" && importEntry !== null) {
15557
- const importMap = importEntry;
15558
- if (typeof importMap.default === "string") return importMap.default;
16207
+ function resolveMode(args, env) {
16208
+ const firstArg = args[0];
16209
+ if (firstArg === "policy-check") return {
16210
+ type: "policy-check"
16211
+ };
16212
+ if (firstArg === "record") return {
16213
+ type: "record"
16214
+ };
16215
+ if (firstArg === "init") {
16216
+ const options = parseInitOptions(args);
16217
+ return {
16218
+ type: "init",
16219
+ ...options
16220
+ };
16221
+ }
16222
+ if (firstArg === "policy" && args[1] === "--init") {
16223
+ const options = parsePolicyInitOptions(args);
16224
+ return {
16225
+ type: "policy-init",
16226
+ model: options.model
16227
+ };
16228
+ }
16229
+ if (env.AGENTSHELL_PASSTHROUGH === "1") {
16230
+ return {
16231
+ type: "passthrough",
16232
+ args
16233
+ };
15559
16234
  }
15560
- return null;
16235
+ if (firstArg === "--version") return {
16236
+ type: "version"
16237
+ };
16238
+ if (firstArg === "-c" && args[1]) return {
16239
+ type: "shim",
16240
+ command: args[1]
16241
+ };
16242
+ if (firstArg === "log") return {
16243
+ type: "log"
16244
+ };
16245
+ return {
16246
+ type: "usage"
16247
+ };
15561
16248
  }
15562
16249
 
15563
- ;// CONCATENATED MODULE: ./src/copilot-enhance.ts
15564
-
15565
-
16250
+ ;// CONCATENATED MODULE: ./src/policy.ts
15566
16251
 
15567
16252
 
15568
16253
 
15569
16254
 
15570
- const DEFAULT_MODEL = "claude-sonnet-4-6";
15571
- const MAX_FILE_READ_BYTES = 102_400;
15572
- const AnalysisResponseSchema = schemas_object({
15573
- additionalAllowRules: schemas_array(schemas_string().max(512)).max(100),
15574
- suggestions: schemas_array(schemas_string().max(1024)).max(100)
15575
- });
16255
+ const DEFAULT_POLICY_SUBPATH = ".github/hooks/agent-shell/policy.json";
15576
16256
  /**
15577
- * Resolves a relative path against a root directory and verifies it
15578
- * does not escape the root via traversal (e.g. `../../etc/passwd`).
16257
+ * Glob matcher supporting only `*` wildcards.
15579
16258
  *
15580
- * @returns The resolved absolute path, or null if it escapes the root
15581
- */ function resolveSafePath(repoRoot, relativePath) {
15582
- const root = repoRoot.replace(/\/+$/, "") || "/";
15583
- const resolved = (0,external_node_path_namespaceObject.resolve)(root, (0,external_node_path_namespaceObject.normalize)(relativePath));
15584
- const prefix = root === "/" ? "/" : `${root}/`;
15585
- if (!resolved.startsWith(prefix) && resolved !== root) {
15586
- return null;
16259
+ * Splits the rule on `*` into literal segments and checks that each
16260
+ * segment appears in the command in order. O(n·m) worst case where
16261
+ * n = command length and m = rule length, with no exponential
16262
+ * backtracking (unlike regex `.*` quantifiers).
16263
+ */ function matchesRule(command, rule) {
16264
+ const segments = rule.split("*");
16265
+ if (segments.length === 1) {
16266
+ return command === rule;
15587
16267
  }
15588
- return resolved;
16268
+ const prefixSegment = segments[0];
16269
+ const suffixSegment = segments[segments.length - 1];
16270
+ const innerSegments = segments.slice(1, -1);
16271
+ if (!command.startsWith(prefixSegment)) {
16272
+ return false;
16273
+ }
16274
+ let cursor = prefixSegment.length;
16275
+ for (const segment of innerSegments){
16276
+ const index = command.indexOf(segment, cursor);
16277
+ if (index === -1) {
16278
+ return false;
16279
+ }
16280
+ cursor = index + segment.length;
16281
+ }
16282
+ const suffixStart = command.length - suffixSegment.length;
16283
+ return suffixStart >= cursor && command.endsWith(suffixSegment);
15589
16284
  }
15590
- /**
15591
- * Reads a project file safely, enforcing path traversal protection
15592
- * and byte-level truncation. Extracted for testability.
15593
- */ async function readProjectFileSafe(repoRoot, pathArg) {
15594
- if (pathArg.length === 0) {
16285
+ function evaluatePolicy(policy, command) {
16286
+ if (policy === null) {
15595
16287
  return {
15596
- error: "Path is required"
16288
+ decision: "allow",
16289
+ matchedRule: null
15597
16290
  };
15598
16291
  }
15599
- const safePath = resolveSafePath(repoRoot, pathArg);
15600
- if (safePath === null) {
16292
+ const trimmed = command.trim();
16293
+ if (SHELL_METACHAR_PATTERN.test(trimmed)) {
15601
16294
  return {
15602
- error: "Path is outside the repository"
16295
+ decision: "deny",
16296
+ matchedRule: null
15603
16297
  };
15604
16298
  }
15605
- try {
15606
- const root = repoRoot.replace(/\/+$/, "") || "/";
15607
- const [realRoot, realPath] = await Promise.all([
15608
- (0,promises_namespaceObject.realpath)(root),
15609
- (0,promises_namespaceObject.realpath)(safePath)
15610
- ]);
15611
- const realPrefix = realRoot === "/" ? "/" : `${realRoot}/`;
15612
- if (!realPath.startsWith(realPrefix) && realPath !== realRoot) {
16299
+ for (const rule of policy.deny){
16300
+ if (matchesRule(trimmed, rule)) {
15613
16301
  return {
15614
- error: "Path is outside the repository"
16302
+ decision: "deny",
16303
+ matchedRule: rule
16304
+ };
16305
+ }
16306
+ }
16307
+ if (policy.allow !== undefined) {
16308
+ const matchesAny = policy.allow.some((rule)=>matchesRule(trimmed, rule));
16309
+ if (!matchesAny) {
16310
+ return {
16311
+ decision: "deny",
16312
+ matchedRule: null
15615
16313
  };
15616
16314
  }
15617
- const fileBuffer = await (0,promises_namespaceObject.readFile)(realPath);
15618
- const isTruncated = fileBuffer.length > MAX_FILE_READ_BYTES;
15619
- const limitedBuffer = isTruncated ? fileBuffer.subarray(0, MAX_FILE_READ_BYTES) : fileBuffer;
15620
- return {
15621
- content: limitedBuffer.toString("utf-8"),
15622
- truncated: isTruncated
15623
- };
15624
- } catch {
15625
- return {
15626
- error: "File not found or unreadable"
15627
- };
15628
16315
  }
16316
+ return {
16317
+ decision: "allow",
16318
+ matchedRule: null
16319
+ };
15629
16320
  }
15630
16321
  /**
15631
- * Creates custom tools that allow the Copilot model to read files
15632
- * and validate proposed allow rules. Structured project discovery
15633
- * is handled by the lousy-agents MCP server (connected via mcpServers).
15634
- */ function createCustomTools(repoRoot, defineTool) {
15635
- const readProjectFile = defineTool("read_project_file", {
15636
- description: "Read a file from the project repository. Returns file content (truncated at 100KB). Use to inspect build configs, Dockerfiles, Makefiles, etc.",
15637
- parameters: {
15638
- type: "object",
15639
- properties: {
15640
- path: {
15641
- type: "string",
15642
- description: "Relative path from repository root"
15643
- }
15644
- },
15645
- required: [
15646
- "path"
15647
- ]
15648
- },
15649
- skipPermission: true,
15650
- handler: (args)=>readProjectFileSafe(repoRoot, args.path ?? "")
16322
+ * Escapes ASCII control characters in a path before embedding it in an error
16323
+ * message. Prevents log/terminal injection when the path originates from an
16324
+ * environment variable (e.g. AGENTSHELL_POLICY_PATH).
16325
+ */ function sanitizePath(path) {
16326
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching control characters for sanitization
16327
+ return path.replace(/[\u0000-\u001f\u007f]/g, (ch)=>{
16328
+ return `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`;
15651
16329
  });
15652
- const validateAllowRule = defineTool("validate_allow_rule", {
15653
- description: "Check whether a proposed allow rule is safe (does not contain shell metacharacters like ;, |, &, `, $, etc.).",
15654
- parameters: {
15655
- type: "object",
15656
- properties: {
15657
- command: {
15658
- type: "string",
15659
- description: "The command to validate as a policy rule"
15660
- }
15661
- },
15662
- required: [
15663
- "command"
15664
- ]
15665
- },
15666
- skipPermission: true,
15667
- handler: async (args)=>{
15668
- const command = args.command ?? "";
15669
- if (isSafeCommand(command)) {
15670
- return {
15671
- safe: true,
15672
- reason: "No shell metacharacters detected"
15673
- };
15674
- }
15675
- const normalized = command.trim();
15676
- if (normalized.length === 0) {
15677
- return {
15678
- safe: false,
15679
- reason: "Empty or whitespace-only commands are not allowed in policy rules"
15680
- };
15681
- }
16330
+ }
16331
+ function resolvePolicyPath(env, repoRoot) {
16332
+ const override = env.AGENTSHELL_POLICY_PATH;
16333
+ if (override !== undefined && override !== "") {
16334
+ if ((0,external_node_path_namespaceObject.isAbsolute)(override)) {
16335
+ // Absolute path — use as-is (will be validated after realpath)
15682
16336
  return {
15683
- safe: false,
15684
- reason: "Contains shell metacharacters — compound commands are not allowed in policy rules"
16337
+ path: override,
16338
+ isOverride: true
15685
16339
  };
15686
16340
  }
15687
- });
15688
- return [
15689
- readProjectFile,
15690
- validateAllowRule
15691
- ];
16341
+ // Relative path — resolve relative to repo root
16342
+ return {
16343
+ path: (0,external_node_path_namespaceObject.join)(repoRoot, override),
16344
+ isOverride: true
16345
+ };
16346
+ }
16347
+ return {
16348
+ path: (0,external_node_path_namespaceObject.join)(repoRoot, DEFAULT_POLICY_SUBPATH),
16349
+ isOverride: false
16350
+ };
15692
16351
  }
15693
- /**
15694
- * Attempts to use the @github/copilot-sdk to enhance policy generation
15695
- * with AI-powered project analysis. Connects to the lousy-agents MCP
15696
- * server for structured project discovery (feedback loops, environment)
15697
- * and provides custom tools for file reading and rule validation.
15698
- * Falls back gracefully if the SDK or Copilot CLI is not available.
15699
- *
15700
- * @returns Enhanced analysis results, or null if the SDK is unavailable
15701
- */ async function enhanceWithCopilot(scanResult, repoRoot, writeStderr, model = DEFAULT_MODEL) {
15702
- let importSucceeded = false;
15703
- try {
15704
- const sdkPath = resolveSdkPath(repoRoot, "@github/copilot-sdk");
15705
- const { CopilotClient, defineTool, approveAll } = sdkPath ? await import(/* webpackIgnore: true */ sdkPath) : await __webpack_require__.e(/* import() */ "300").then(__webpack_require__.bind(__webpack_require__, 791));
15706
- importSucceeded = true;
15707
- const client = new CopilotClient();
15708
- const tools = createCustomTools(repoRoot, defineTool);
15709
- try {
15710
- await client.start();
15711
- const session = await client.createSession({
15712
- model,
15713
- tools,
15714
- onPermissionRequest: approveAll,
15715
- systemMessage: {
15716
- content: buildSystemMessage()
15717
- },
15718
- mcpServers: {
15719
- "lousy-agents": {
15720
- type: "local",
15721
- command: "npx",
15722
- args: [
15723
- "-y",
15724
- "@lousy-agents/mcp"
15725
- ],
15726
- cwd: repoRoot,
15727
- tools: [
15728
- "discover_feedback_loops",
15729
- "discover_environment"
15730
- ]
15731
- }
15732
- }
15733
- });
15734
- try {
15735
- const prompt = buildAnalysisPrompt(scanResult, repoRoot);
15736
- const response = await session.sendAndWait({
15737
- prompt
15738
- });
15739
- const data = typeof response?.data === "object" && response.data !== null ? response.data : undefined;
15740
- const content = data !== undefined && "content" in data && typeof data.content === "string" ? data.content : "";
15741
- return parseAnalysisResponse(content);
15742
- } finally{
15743
- await session.disconnect();
15744
- }
15745
- } finally{
15746
- await client.stop();
15747
- }
15748
- } catch (err) {
15749
- if (process.env.AGENT_SHELL_COPILOT_DEBUG) {
15750
- if (importSucceeded) {
15751
- writeStderr(`agent-shell: Copilot analysis failed — ${sanitizeForStderr(err)}\n`);
15752
- } else {
15753
- writeStderr("agent-shell: Copilot SDK not available — using static analysis only\n");
16352
+ async function loadPolicy(env, deps) {
16353
+ const rawRepoRoot = deps.getRepositoryRoot();
16354
+ const repoRoot = await deps.realpath(rawRepoRoot);
16355
+ const { path: candidatePath, isOverride } = resolvePolicyPath(env, repoRoot);
16356
+ let resolvedPath;
16357
+ try {
16358
+ resolvedPath = await deps.realpath(candidatePath);
16359
+ } catch (error) {
16360
+ if (isPathNotFoundError(error)) {
16361
+ if (isOverride) {
16362
+ throw new Error(`Policy override path does not exist: ${sanitizePath(candidatePath)}`);
15754
16363
  }
16364
+ return null;
15755
16365
  }
15756
- return null;
16366
+ throw error;
15757
16367
  }
15758
- }
15759
- /**
15760
- * Finds the first well-formed JSON object in `content` using brace-balancing,
15761
- * rather than a greedy regex that could capture extra trailing braces and
15762
- * fail JSON.parse on valid responses that include preamble or postamble text.
15763
- */ function extractFirstJsonObject(content) {
15764
- const start = content.indexOf("{");
15765
- if (start === -1) {
15766
- return null;
16368
+ if (!isWithinProjectRoot(resolvedPath, repoRoot)) {
16369
+ throw new Error(`Policy file path resolves outside the repository root: ${sanitizePath(resolvedPath)}`);
15767
16370
  }
15768
- let inString = false;
15769
- let escaping = false;
15770
- let depth = 0;
15771
- for(let i = start; i < content.length; i += 1){
15772
- const ch = content[i];
15773
- if (escaping) {
15774
- escaping = false;
15775
- continue;
15776
- }
15777
- if (ch === "\\") {
15778
- if (inString) {
15779
- escaping = true;
15780
- }
15781
- continue;
15782
- }
15783
- if (ch === '"') {
15784
- inString = !inString;
15785
- continue;
15786
- }
15787
- if (!inString) {
15788
- if (ch === "{") {
15789
- depth += 1;
15790
- } else if (ch === "}") {
15791
- depth -= 1;
15792
- if (depth === 0) {
15793
- return content.slice(start, i + 1);
15794
- }
16371
+ let content;
16372
+ try {
16373
+ content = await deps.readFile(resolvedPath, "utf-8");
16374
+ } catch (error) {
16375
+ if (isPathNotFoundError(error)) {
16376
+ if (isOverride) {
16377
+ throw new Error(`Policy override path does not exist: ${sanitizePath(resolvedPath)}`);
15795
16378
  }
16379
+ return null;
15796
16380
  }
16381
+ throw error;
15797
16382
  }
15798
- return null;
15799
- }
15800
- function parseAnalysisResponse(content) {
15801
- const jsonText = extractFirstJsonObject(content);
15802
- if (!jsonText) {
15803
- return null;
15804
- }
16383
+ let parsed;
15805
16384
  try {
15806
- const parsed = JSON.parse(jsonText);
15807
- return AnalysisResponseSchema.parse(parsed);
16385
+ parsed = JSON.parse(content);
15808
16386
  } catch {
15809
- return null;
16387
+ throw new Error(`Invalid JSON in policy file ${sanitizePath(resolvedPath)}: file exists but contains malformed JSON`);
15810
16388
  }
16389
+ return PolicyConfigSchema.parse(parsed);
15811
16390
  }
15812
16391
 
15813
- ;// CONCATENATED MODULE: ./src/project-scanner.ts
15814
-
16392
+ ;// CONCATENATED MODULE: ./src/actor.ts
16393
+ // Best-effort agent detection env vars (Phase 1):
16394
+ // - CLAUDE_CODE: Set by Claude Code (`CLAUDE_CODE=1`) in its shell sessions
16395
+ // - COPILOT_AGENT: Set by GitHub Copilot coding agent in its shell sessions
16396
+ // - COPILOT_CLI: Set by GitHub Copilot CLI (`COPILOT_CLI=1`) in its shell sessions
16397
+ // - COPILOT_CLI_BINARY_VERSION: Set by GitHub Copilot CLI (e.g. `COPILOT_CLI_BINARY_VERSION=1.0.4`)
16398
+ // If these prove incorrect, the detection rule should be removed entirely.
16399
+ /**
16400
+ * Determines who initiated a script execution based on environment variables.
16401
+ *
16402
+ * Detection priority (first match wins):
16403
+ * 1. Explicit override via AGENTSHELL_ACTOR
16404
+ * 2. CI detection via GITHUB_ACTIONS
16405
+ * 3. Known coding agent detection (Claude Code, GitHub Copilot)
16406
+ * 4. Fallback to "human"
16407
+ */ function detectActor(env) {
16408
+ const override = env.AGENTSHELL_ACTOR;
16409
+ if (override !== undefined && override !== "") {
16410
+ return override;
16411
+ }
16412
+ if (env.GITHUB_ACTIONS === "true") {
16413
+ return "ci";
16414
+ }
16415
+ if (env.CLAUDE_CODE !== undefined && env.CLAUDE_CODE !== "") {
16416
+ return "claude-code";
16417
+ }
16418
+ if (env.COPILOT_AGENT !== undefined && env.COPILOT_AGENT !== "") {
16419
+ return "copilot";
16420
+ }
16421
+ if (env.COPILOT_CLI !== undefined && env.COPILOT_CLI !== "" || env.COPILOT_CLI_BINARY_VERSION !== undefined && env.COPILOT_CLI_BINARY_VERSION !== "") {
16422
+ return "copilot";
16423
+ }
16424
+ return "human";
16425
+ }
15815
16426
 
15816
- const MAX_WORKFLOW_FILES = 100;
15817
- const MAX_FILE_SIZE_BYTES = 524_288;
15818
- const LANGUAGE_MARKERS = {
15819
- "package.json": "node",
15820
- "requirements.txt": "python",
15821
- // biome-ignore lint/style/useNamingConvention: filename on disk
15822
- Pipfile: "python",
15823
- "pyproject.toml": "python",
15824
- "setup.py": "python",
15825
- "go.mod": "go",
15826
- "Cargo.toml": "rust",
15827
- // biome-ignore lint/style/useNamingConvention: filename on disk
15828
- Gemfile: "ruby",
15829
- "pom.xml": "java",
15830
- "build.gradle": "java",
15831
- "build.gradle.kts": "java"
15832
- };
15833
- async function fileExists(path) {
15834
- try {
15835
- const s = await (0,promises_namespaceObject.stat)(path);
15836
- return s.isFile();
15837
- } catch {
15838
- return false;
16427
+ ;// CONCATENATED MODULE: ./src/env-capture.ts
16428
+ const TAG_PREFIX = "AGENTSHELL_TAG_";
16429
+ const MAX_VALUE_BYTES = 1024;
16430
+ const MAX_TAGS = 50;
16431
+ const TRUNCATION_SUFFIX = "…[truncated]";
16432
+ const PROTOTYPE_POLLUTION_KEYS = new Set([
16433
+ "__proto__",
16434
+ "constructor",
16435
+ "prototype"
16436
+ ]);
16437
+ const ALLOWLIST_PREFIXES = [
16438
+ "npm_lifecycle_",
16439
+ "github_",
16440
+ "agentshell_"
16441
+ ];
16442
+ const ALLOWLIST_EXACT = new Set([
16443
+ "npm_package_name",
16444
+ "npm_package_version",
16445
+ "node_env",
16446
+ "ci"
16447
+ ]);
16448
+ const BLOCKLIST_PATTERNS = [
16449
+ "secret",
16450
+ "token",
16451
+ "key",
16452
+ "password",
16453
+ "credential",
16454
+ "auth"
16455
+ ];
16456
+ function isAllowlisted(name) {
16457
+ const lower = name.toLowerCase();
16458
+ if (ALLOWLIST_EXACT.has(lower)) {
16459
+ return true;
15839
16460
  }
16461
+ return ALLOWLIST_PREFIXES.some((prefix)=>lower.startsWith(prefix));
15840
16462
  }
15841
- async function dirExists(path) {
15842
- try {
15843
- const s = await (0,promises_namespaceObject.stat)(path);
15844
- return s.isDirectory();
15845
- } catch {
15846
- return false;
16463
+ function isBlocklisted(name) {
16464
+ const lower = name.toLowerCase();
16465
+ return BLOCKLIST_PATTERNS.some((pattern)=>lower.includes(pattern));
16466
+ }
16467
+ function isTagVariable(name) {
16468
+ return name.startsWith(TAG_PREFIX);
16469
+ }
16470
+ function truncateValue(value) {
16471
+ const bytes = Buffer.byteLength(value, "utf-8");
16472
+ if (bytes <= MAX_VALUE_BYTES) {
16473
+ return {
16474
+ value,
16475
+ truncated: false
16476
+ };
16477
+ }
16478
+ // Codepoint-aware truncation: iterate Unicode codepoints to prevent
16479
+ // splitting multi-byte UTF-8 sequences at arbitrary byte boundaries
16480
+ let byteCount = 0;
16481
+ let truncated = "";
16482
+ for (const ch of value){
16483
+ const charBytes = Buffer.byteLength(ch, "utf-8");
16484
+ if (byteCount + charBytes > MAX_VALUE_BYTES) break;
16485
+ byteCount += charBytes;
16486
+ truncated += ch;
15847
16487
  }
16488
+ return {
16489
+ value: `${truncated}${TRUNCATION_SUFFIX}`,
16490
+ truncated: true
16491
+ };
15848
16492
  }
15849
- async function discoverScripts(targetDir) {
15850
- const packageJsonPath = (0,external_node_path_namespaceObject.join)(targetDir, "package.json");
15851
- if (!await fileExists(packageJsonPath)) {
15852
- return [];
16493
+ function captureEnv(env) {
16494
+ const result = Object.create(null);
16495
+ let anyTruncated = false;
16496
+ for (const [name, value] of Object.entries(env)){
16497
+ if (value === undefined) continue;
16498
+ if (!isAllowlisted(name)) continue;
16499
+ if (isBlocklisted(name)) continue;
16500
+ if (isTagVariable(name)) continue;
16501
+ const { value: finalValue, truncated } = truncateValue(value);
16502
+ result[name] = finalValue;
16503
+ if (truncated) anyTruncated = true;
15853
16504
  }
15854
- try {
15855
- const fileStat = await (0,promises_namespaceObject.stat)(packageJsonPath);
15856
- if (fileStat.size > MAX_FILE_SIZE_BYTES) {
15857
- return [];
15858
- }
15859
- const content = await (0,promises_namespaceObject.readFile)(packageJsonPath, "utf-8");
15860
- const parsed = JSON.parse(content);
15861
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
15862
- return [];
15863
- }
15864
- const pkg = parsed;
15865
- const scripts = pkg.scripts;
15866
- if (scripts === null || scripts === undefined || typeof scripts !== "object" || Array.isArray(scripts)) {
15867
- return [];
15868
- }
15869
- const result = [];
15870
- for (const [name, command] of Object.entries(scripts)){
15871
- if (typeof command === "string") {
15872
- result.push({
15873
- name,
15874
- command
15875
- });
15876
- }
15877
- }
15878
- return result;
15879
- } catch {
15880
- return [];
16505
+ if (anyTruncated) {
16506
+ result._env_truncated = "true";
15881
16507
  }
16508
+ return result;
15882
16509
  }
15883
- async function discoverWorkflowCommands(targetDir) {
15884
- const workflowsDir = (0,external_node_path_namespaceObject.join)(targetDir, ".github", "workflows");
15885
- if (!await dirExists(workflowsDir)) {
15886
- return [];
16510
+ function captureTags(env) {
16511
+ const result = Object.create(null);
16512
+ let anyTruncatedOrDiscarded = false;
16513
+ const entries = [];
16514
+ for (const [name, value] of Object.entries(env)){
16515
+ if (value === undefined) continue;
16516
+ if (!name.startsWith(TAG_PREFIX)) continue;
16517
+ const tagKey = name.slice(TAG_PREFIX.length).toLowerCase();
16518
+ if (PROTOTYPE_POLLUTION_KEYS.has(tagKey)) continue;
16519
+ entries.push([
16520
+ tagKey,
16521
+ value
16522
+ ]);
16523
+ }
16524
+ entries.sort((a, b)=>a[0].localeCompare(b[0]));
16525
+ if (entries.length > MAX_TAGS) {
16526
+ anyTruncatedOrDiscarded = true;
16527
+ entries.length = MAX_TAGS;
15887
16528
  }
15888
- let files;
15889
- try {
15890
- files = await (0,promises_namespaceObject.readdir)(workflowsDir);
15891
- } catch {
15892
- return [];
16529
+ for (const [key, rawValue] of entries){
16530
+ const { value, truncated } = truncateValue(rawValue);
16531
+ result[key] = value;
16532
+ if (truncated) anyTruncatedOrDiscarded = true;
15893
16533
  }
15894
- const yamlFiles = files.filter((f)=>f.endsWith(".yml") || f.endsWith(".yaml")).slice(0, MAX_WORKFLOW_FILES);
15895
- const allCommands = [];
15896
- for (const file of yamlFiles){
16534
+ if (anyTruncatedOrDiscarded) {
16535
+ result._tags_truncated = "true";
16536
+ }
16537
+ return result;
16538
+ }
16539
+
16540
+ ;// CONCATENATED MODULE: ./src/telemetry.ts
16541
+ // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
16542
+
16543
+
16544
+
16545
+
16546
+
16547
+ const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
16548
+ const DEFAULT_EVENTS_SUBDIR = ".agent-shell/events";
16549
+ async function realpathExistingAncestor(targetPath, deps) {
16550
+ let current = targetPath;
16551
+ while(true){
15897
16552
  try {
15898
- const filePath = (0,external_node_path_namespaceObject.join)(workflowsDir, file);
15899
- const fileStat = await (0,promises_namespaceObject.stat)(filePath);
15900
- if (fileStat.size > MAX_FILE_SIZE_BYTES) {
15901
- continue;
15902
- }
15903
- const content = await (0,promises_namespaceObject.readFile)(filePath, "utf-8");
15904
- const commands = extractRunCommandsFromYaml(content);
15905
- allCommands.push(...commands);
15906
- } catch {}
16553
+ return await deps.realpath(current);
16554
+ } catch (err) {
16555
+ if (!isPathNotFoundError(err)) throw err;
16556
+ const parent = (0,external_node_path_namespaceObject.dirname)(current);
16557
+ if (parent === current) return null;
16558
+ current = parent;
16559
+ }
15907
16560
  }
15908
- return [
15909
- ...new Set(allCommands)
15910
- ].filter(isUsefulCommand);
15911
16561
  }
15912
- /**
15913
- * Patterns that match shell control structures, variable assignments,
15914
- * and other non-command lines extracted from multi-line workflow `run:`
15915
- * blocks. These are filtered out because they are not meaningful tool
15916
- * invocations that should appear in a policy allow list — only actual
15917
- * commands (npm, mise, node, git, etc.) are useful for policy rules.
15918
- */ const SHELL_NOISE_PATTERNS = [
15919
- /^(if|then|else|elif|fi|for|while|do|done|case|esac)\b/,
15920
- /^(set\s+[+-]e|set\s+[+-]o)/,
15921
- /^(exit\s+\d|exit\s+\$)/,
15922
- /^\w+="?\$\{\{/,
15923
- /^[A-Z_]+=("[^"]*"|'[^']*'|\S+)\s*\\?$/,
15924
- /^(echo|printf)\s/,
15925
- /^(cd|mkdir|rm|test)\s/,
15926
- /^>\s/,
15927
- /^(else|fi|done|esac)$/,
15928
- /^\w+=\$\?$/,
15929
- /^\\$/,
15930
- /^'[^']*'\s*\\?$/,
15931
- /^"[^"]*"\s*\\?$/,
15932
- /^node\s+"?\$/
15933
- ];
15934
- function isUsefulCommand(cmd) {
15935
- if (cmd.length < 3) return false;
15936
- if (cmd.endsWith(")") && !cmd.includes("(")) return false;
15937
- return !SHELL_NOISE_PATTERNS.some((pattern)=>pattern.test(cmd));
16562
+ function resolveSessionId(env, deps) {
16563
+ const provided = env.AGENTSHELL_SESSION_ID;
16564
+ if (provided === undefined || provided === "") {
16565
+ return deps.randomUUID();
16566
+ }
16567
+ if (provided.includes("..") || provided.includes("/") || provided.includes("\\") || !SESSION_ID_PATTERN.test(provided)) {
16568
+ deps.writeStderr(`agent-shell: invalid AGENTSHELL_SESSION_ID "${provided}", generating new ID\n`);
16569
+ return deps.randomUUID();
16570
+ }
16571
+ return provided;
15938
16572
  }
15939
- /**
15940
- * Simple extraction of `run:` values from YAML content.
15941
- * Uses line-based parsing rather than a full YAML parser to avoid
15942
- * adding yaml as a dependency to agent-shell.
15943
- */ function extractRunCommandsFromYaml(content) {
15944
- const commands = [];
15945
- const lines = content.split("\n");
15946
- let inRunBlock = false;
15947
- let runIndent = 0;
15948
- let isFoldedBlock = false;
15949
- let foldedLines = [];
15950
- let continuationBuffer = "";
15951
- function flushFoldedBlock() {
15952
- if (foldedLines.length > 0) {
15953
- commands.push(foldedLines.join(" "));
15954
- foldedLines = [];
16573
+ async function resolveWriteEventsDir(env, deps) {
16574
+ const projectRoot = deps.cwd();
16575
+ const defaultDir = (0,external_node_path_namespaceObject.join)(projectRoot, DEFAULT_EVENTS_SUBDIR);
16576
+ const logDir = env.AGENTSHELL_LOG_DIR;
16577
+ if (logDir !== undefined && logDir !== "") {
16578
+ // Canonicalize projectRoot for real-path comparisons; handles symlinked cwd
16579
+ const projectRootReal = await deps.realpath(projectRoot);
16580
+ // Reject external paths (allowing either logical or real project root)
16581
+ const resolvedLogical = (0,external_node_path_namespaceObject.resolve)(projectRoot, logDir);
16582
+ if (!isWithinProjectRoot(resolvedLogical, projectRoot) && !isWithinProjectRoot(resolvedLogical, projectRootReal)) {
16583
+ deps.writeStderr(`agent-shell: AGENTSHELL_LOG_DIR resolves outside project root, using default\n`);
16584
+ await deps.mkdir(defaultDir, {
16585
+ recursive: true
16586
+ });
16587
+ return defaultDir;
15955
16588
  }
15956
- }
15957
- function flushContinuation() {
15958
- if (continuationBuffer.length > 0) {
15959
- commands.push(continuationBuffer);
15960
- continuationBuffer = "";
16589
+ // Validate existing ancestor realpath before mkdir to prevent symlink escape
16590
+ const ancestorReal = await realpathExistingAncestor(resolvedLogical, deps);
16591
+ if (ancestorReal === null || !isWithinProjectRoot(ancestorReal, projectRootReal)) {
16592
+ deps.writeStderr(`agent-shell: AGENTSHELL_LOG_DIR resolves outside project root via ancestor symlink, using default\n`);
16593
+ await deps.mkdir(defaultDir, {
16594
+ recursive: true
16595
+ });
16596
+ return defaultDir;
16597
+ }
16598
+ await deps.mkdir(resolvedLogical, {
16599
+ recursive: true
16600
+ });
16601
+ const resolved = await deps.realpath(resolvedLogical);
16602
+ if (!isWithinProjectRoot(resolved, projectRootReal)) {
16603
+ deps.writeStderr(`agent-shell: AGENTSHELL_LOG_DIR resolves outside project root, using default\n`);
16604
+ await deps.mkdir(defaultDir, {
16605
+ recursive: true
16606
+ });
16607
+ return defaultDir;
15961
16608
  }
16609
+ return resolved;
15962
16610
  }
15963
- for(let i = 0; i < lines.length; i++){
15964
- const line = lines[i];
15965
- if (line === undefined) continue;
15966
- const trimmed = line.trimStart();
15967
- const indent = line.length - trimmed.length;
15968
- if (inRunBlock) {
15969
- if (trimmed.length === 0) {
15970
- continue;
15971
- }
15972
- if (indent > runIndent) {
15973
- let cmd = trimmed.trimEnd();
15974
- cmd = cmd.replace(/\s+#.*$/, "").trimEnd();
15975
- const isContinuation = cmd.endsWith("\\");
15976
- if (isContinuation) {
15977
- cmd = cmd.slice(0, -1).trimEnd();
15978
- }
15979
- if (cmd.length > 0 && !cmd.startsWith("#")) {
15980
- if (isFoldedBlock) {
15981
- foldedLines.push(cmd);
15982
- } else if (isContinuation) {
15983
- continuationBuffer = continuationBuffer.length > 0 ? `${continuationBuffer} ${cmd}` : cmd;
15984
- } else if (continuationBuffer.length > 0) {
15985
- commands.push(`${continuationBuffer} ${cmd}`);
15986
- continuationBuffer = "";
15987
- } else {
15988
- commands.push(cmd);
15989
- }
15990
- }
15991
- } else {
15992
- flushContinuation();
15993
- if (isFoldedBlock) {
15994
- flushFoldedBlock();
15995
- }
15996
- inRunBlock = false;
15997
- isFoldedBlock = false;
15998
- }
16611
+ await deps.mkdir(defaultDir, {
16612
+ recursive: true
16613
+ });
16614
+ return defaultDir;
16615
+ }
16616
+ async function writeEvent(eventsDir, sessionId, event, deps) {
16617
+ const filePath = (0,external_node_path_namespaceObject.join)(eventsDir, `${sessionId}.jsonl`);
16618
+ const line = `${JSON.stringify(event)}\n`;
16619
+ await deps.appendFile(filePath, line);
16620
+ }
16621
+ async function emitScriptEndEvent(options, deps) {
16622
+ const sessionId = resolveSessionId(options.env, deps);
16623
+ const eventsDir = await resolveWriteEventsDir(options.env, deps);
16624
+ const capturedEnv = captureEnv(options.env);
16625
+ const tags = captureTags(options.env);
16626
+ const event = {
16627
+ v: SCHEMA_VERSION,
16628
+ session_id: sessionId,
16629
+ event: "script_end",
16630
+ command: options.command,
16631
+ actor: detectActor(options.env),
16632
+ exit_code: options.result.exitCode,
16633
+ signal: options.result.signal,
16634
+ duration_ms: options.result.durationMs,
16635
+ timestamp: deps.now(),
16636
+ env: capturedEnv,
16637
+ tags,
16638
+ ...options.env.npm_lifecycle_event && {
16639
+ script: options.env.npm_lifecycle_event
16640
+ },
16641
+ ...options.env.npm_package_name && {
16642
+ package: options.env.npm_package_name
16643
+ },
16644
+ ...options.env.npm_package_version && {
16645
+ package_version: options.env.npm_package_version
15999
16646
  }
16000
- if (!inRunBlock) {
16001
- const runMatch = trimmed.match(/^-?\s*run:\s*(.*)$/);
16002
- if (runMatch) {
16003
- const value = runMatch[1]?.trim();
16004
- const firstToken = value ? value.split(/[ \t#]/, 1)[0] ?? "" : "";
16005
- if (/^[|>][-+]?$/.test(firstToken)) {
16006
- inRunBlock = true;
16007
- runIndent = indent;
16008
- isFoldedBlock = firstToken.startsWith(">");
16009
- foldedLines = [];
16010
- } else if (value && value.length > 0) {
16011
- let command;
16012
- const quoteMatch = value.match(/^(["'])(.*)\1/);
16013
- if (quoteMatch) {
16014
- command = quoteMatch[2] ?? "";
16015
- } else {
16016
- command = value.replace(/\s+#.*$/, "").trim();
16017
- }
16018
- if (command.length > 0 && !command.startsWith("#")) {
16019
- commands.push(command);
16020
- }
16021
- }
16647
+ };
16648
+ await writeEvent(eventsDir, sessionId, event, deps);
16649
+ }
16650
+ async function emitShimErrorEvent(options, deps) {
16651
+ const sessionId = resolveSessionId(options.env, deps);
16652
+ const eventsDir = await resolveWriteEventsDir(options.env, deps);
16653
+ const capturedEnv = captureEnv(options.env);
16654
+ const tags = captureTags(options.env);
16655
+ const event = {
16656
+ v: SCHEMA_VERSION,
16657
+ session_id: sessionId,
16658
+ event: "shim_error",
16659
+ command: options.command,
16660
+ actor: detectActor(options.env),
16661
+ timestamp: deps.now(),
16662
+ env: capturedEnv,
16663
+ tags
16664
+ };
16665
+ await writeEvent(eventsDir, sessionId, event, deps);
16666
+ }
16667
+ async function emitPolicyDecisionEvent(options, deps) {
16668
+ const depsWithProjectRoot = {
16669
+ ...deps,
16670
+ cwd: ()=>options.projectRoot
16671
+ };
16672
+ const sessionId = resolveSessionId(options.env, deps);
16673
+ const eventsDir = await resolveWriteEventsDir(options.env, depsWithProjectRoot);
16674
+ const capturedEnv = captureEnv(options.env);
16675
+ const tags = captureTags(options.env);
16676
+ const event = {
16677
+ v: SCHEMA_VERSION,
16678
+ session_id: sessionId,
16679
+ event: "policy_decision",
16680
+ command: options.command,
16681
+ decision: options.decision,
16682
+ matched_rule: options.matched_rule,
16683
+ actor: detectActor(options.env),
16684
+ timestamp: deps.now(),
16685
+ env: capturedEnv,
16686
+ tags
16687
+ };
16688
+ await writeEvent(eventsDir, sessionId, event, depsWithProjectRoot);
16689
+ }
16690
+ async function emitToolUseEvent(options, deps) {
16691
+ const depsWithProjectRoot = {
16692
+ ...deps,
16693
+ cwd: ()=>options.projectRoot
16694
+ };
16695
+ const sessionId = resolveSessionId(options.env, deps);
16696
+ const eventsDir = await resolveWriteEventsDir(options.env, depsWithProjectRoot);
16697
+ const capturedEnv = captureEnv(options.env);
16698
+ const tags = captureTags(options.env);
16699
+ const event = {
16700
+ v: SCHEMA_VERSION,
16701
+ session_id: sessionId,
16702
+ event: "tool_use",
16703
+ tool_name: options.tool_name,
16704
+ command: options.command,
16705
+ actor: detectActor(options.env),
16706
+ timestamp: deps.now(),
16707
+ env: capturedEnv,
16708
+ tags
16709
+ };
16710
+ await writeEvent(eventsDir, sessionId, event, depsWithProjectRoot);
16711
+ }
16712
+
16713
+ ;// CONCATENATED MODULE: ./src/policy-check.ts
16714
+ // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
16715
+
16716
+
16717
+
16718
+
16719
+ const TERMINAL_TOOLS = new Set([
16720
+ "bash",
16721
+ "zsh",
16722
+ "ash",
16723
+ "sh"
16724
+ ]);
16725
+ const HookInputSchema = schemas_object({
16726
+ toolName: schemas_string(),
16727
+ toolArgs: unknown().optional()
16728
+ });
16729
+ function allowResponse() {
16730
+ return JSON.stringify({
16731
+ permissionDecision: "allow"
16732
+ });
16733
+ }
16734
+ function denyResponse(reason) {
16735
+ return JSON.stringify({
16736
+ permissionDecision: "deny",
16737
+ permissionDecisionReason: reason
16738
+ });
16739
+ }
16740
+ const TELEMETRY_TIMEOUT_MS = 5_000;
16741
+ async function tryEmitTelemetry(deps, command, decision, matchedRule) {
16742
+ try {
16743
+ const repoRoot = deps.policyDeps.getRepositoryRoot();
16744
+ const emission = emitPolicyDecisionEvent({
16745
+ command,
16746
+ decision,
16747
+ matched_rule: matchedRule,
16748
+ env: deps.env,
16749
+ projectRoot: repoRoot
16750
+ }, deps.telemetryDeps);
16751
+ // Prevent unhandled rejection if emission rejects after timeout wins the race
16752
+ emission.catch(()=>{});
16753
+ let timeoutHandle;
16754
+ const timeout = new Promise((_, reject)=>{
16755
+ timeoutHandle = setTimeout(()=>reject(new Error("telemetry write timed out")), TELEMETRY_TIMEOUT_MS);
16756
+ timeoutHandle.unref();
16757
+ });
16758
+ try {
16759
+ await Promise.race([
16760
+ emission,
16761
+ timeout
16762
+ ]);
16763
+ } finally{
16764
+ if (timeoutHandle !== undefined) {
16765
+ clearTimeout(timeoutHandle);
16022
16766
  }
16023
16767
  }
16768
+ } catch (err) {
16769
+ deps.writeStderr(`agent-shell: telemetry write error: ${sanitizeForStderr(err)}\n`);
16024
16770
  }
16025
- flushContinuation();
16026
- if (isFoldedBlock) {
16027
- flushFoldedBlock();
16028
- }
16029
- return commands;
16030
16771
  }
16031
- /**
16032
- * Parse mise.toml to extract task definitions.
16033
- * Uses simple line-based parsing for [tasks.*] sections.
16034
- */ async function discoverMiseTasks(targetDir) {
16035
- const miseTomlPath = (0,external_node_path_namespaceObject.join)(targetDir, "mise.toml");
16036
- if (!await fileExists(miseTomlPath)) {
16037
- return [];
16038
- }
16772
+ async function handlePolicyCheck(deps) {
16039
16773
  try {
16040
- const fileStat = await (0,promises_namespaceObject.stat)(miseTomlPath);
16041
- if (fileStat.size > MAX_FILE_SIZE_BYTES) {
16042
- return [];
16774
+ const rawStdin = await deps.readStdin();
16775
+ let input;
16776
+ try {
16777
+ input = JSON.parse(rawStdin);
16778
+ } catch {
16779
+ deps.writeStderr("agent-shell: failed to parse stdin as JSON\n");
16780
+ deps.writeStdout(denyResponse("Invalid JSON input"));
16781
+ return;
16043
16782
  }
16044
- const content = await (0,promises_namespaceObject.readFile)(miseTomlPath, "utf-8");
16045
- return parseMiseTomlTasks(content);
16046
- } catch {
16047
- return [];
16048
- }
16049
- }
16050
- function parseMiseTomlTasks(content) {
16051
- const tasks = [];
16052
- const lines = content.split("\n");
16053
- let currentTaskName = null;
16054
- let inMultiLineRun = false;
16055
- let multiLineCommand = "";
16056
- for (const line of lines){
16057
- const trimmed = line.trim();
16058
- if (inMultiLineRun) {
16059
- if (trimmed === '"""' || trimmed === "'''") {
16060
- inMultiLineRun = false;
16061
- const cmd = multiLineCommand.trim();
16062
- if (currentTaskName !== null && cmd.length > 0) {
16063
- const firstLine = cmd.split("\n").map((l)=>l.trim()).find((l)=>l.length > 0);
16064
- if (firstLine) {
16065
- tasks.push({
16066
- name: currentTaskName,
16067
- command: firstLine
16068
- });
16069
- }
16070
- }
16071
- multiLineCommand = "";
16072
- continue;
16073
- }
16074
- multiLineCommand += `${trimmed}\n`;
16075
- continue;
16783
+ const hookResult = HookInputSchema.safeParse(input);
16784
+ if (!hookResult.success) {
16785
+ deps.writeStdout(denyResponse("Missing or invalid toolName field"));
16786
+ return;
16076
16787
  }
16077
- const sectionMatch = trimmed.match(/^\[tasks\.([^\]]+)\]$/);
16078
- if (sectionMatch?.[1]) {
16079
- currentTaskName = sectionMatch[1];
16080
- continue;
16788
+ const { toolName, toolArgs } = hookResult.data;
16789
+ // Step 3 (per spec): load and validate policy before terminal tool check
16790
+ // (fail-closed on invalid policy, even for non-terminal tools)
16791
+ let policy;
16792
+ try {
16793
+ policy = await loadPolicy(deps.env, deps.policyDeps);
16794
+ } catch (err) {
16795
+ deps.writeStderr(`agent-shell: policy load error: ${sanitizeForStderr(err)}\n`);
16796
+ deps.writeStdout(denyResponse("Failed to load policy"));
16797
+ return;
16081
16798
  }
16082
- if (trimmed.startsWith("[") && !trimmed.startsWith("[tasks.")) {
16083
- currentTaskName = null;
16084
- continue;
16799
+ // Step 4 (per spec): non-terminal tools pass through with allow.
16800
+ // Per spec: command field is empty string for non-terminal tool decisions
16801
+ // (toolArgs is never parsed, so no command string is available).
16802
+ if (!TERMINAL_TOOLS.has(toolName)) {
16803
+ deps.writeStdout(allowResponse());
16804
+ await tryEmitTelemetry(deps, "", "allow", null);
16805
+ return;
16085
16806
  }
16086
- if (currentTaskName !== null) {
16087
- if (/^run\s*=\s*"""/.test(trimmed) || /^run\s*=\s*'''/.test(trimmed)) {
16088
- inMultiLineRun = true;
16089
- multiLineCommand = "";
16090
- continue;
16091
- }
16092
- const runMatch = trimmed.match(/^run\s*=\s*(?:"([^"]*)"|'([^']*)')/);
16093
- if (runMatch) {
16094
- const command = runMatch[1] ?? runMatch[2] ?? "";
16095
- if (command.length > 0) {
16096
- tasks.push({
16097
- name: currentTaskName,
16098
- command
16099
- });
16100
- }
16101
- }
16807
+ // Terminal tool validate toolArgs
16808
+ if (typeof toolArgs !== "string") {
16809
+ deps.writeStdout(denyResponse("Missing or non-string toolArgs for terminal tool"));
16810
+ return;
16102
16811
  }
16103
- }
16104
- return tasks;
16105
- }
16106
- async function detectLanguages(targetDir) {
16107
- const detected = new Set();
16108
- for (const [filename, language] of Object.entries(LANGUAGE_MARKERS)){
16109
- if (await fileExists((0,external_node_path_namespaceObject.join)(targetDir, filename))) {
16110
- detected.add(language);
16812
+ let parsedArgs;
16813
+ try {
16814
+ parsedArgs = JSON.parse(toolArgs);
16815
+ } catch {
16816
+ deps.writeStderr("agent-shell: failed to parse toolArgs as JSON\n");
16817
+ deps.writeStdout(denyResponse("Invalid JSON in toolArgs"));
16818
+ return;
16111
16819
  }
16112
- }
16113
- return [
16114
- ...detected
16115
- ];
16116
- }
16117
- /**
16118
- * Scans a project directory to discover tools, commands, and languages.
16119
- * Uses static file analysis without requiring external dependencies.
16120
- */ async function scanProject(targetDir) {
16121
- const [scripts, workflowCommands, miseTasks, languages] = await Promise.all([
16122
- discoverScripts(targetDir),
16123
- discoverWorkflowCommands(targetDir),
16124
- discoverMiseTasks(targetDir),
16125
- detectLanguages(targetDir)
16126
- ]);
16127
- return {
16128
- scripts,
16129
- workflowCommands,
16130
- miseTasks,
16131
- languages
16132
- };
16820
+ // toolArgs must be a non-null plain object
16821
+ if (parsedArgs === null || typeof parsedArgs !== "object" || Array.isArray(parsedArgs)) {
16822
+ deps.writeStdout(denyResponse("toolArgs must be a non-null plain object for terminal tools"));
16823
+ return;
16824
+ }
16825
+ const obj = parsedArgs;
16826
+ if (!Object.hasOwn(obj, "command")) {
16827
+ deps.writeStdout(denyResponse("Missing command field in toolArgs"));
16828
+ return;
16829
+ }
16830
+ if (typeof obj.command !== "string") {
16831
+ deps.writeStdout(denyResponse("command field must be a string"));
16832
+ return;
16833
+ }
16834
+ const command = obj.command;
16835
+ const result = evaluatePolicy(policy, command);
16836
+ const responseJson = result.decision === "allow" ? allowResponse() : denyResponse(`Command '${command}' denied by policy rule: ${result.matchedRule ?? "not in allow list"}`);
16837
+ deps.writeStdout(responseJson);
16838
+ await tryEmitTelemetry(deps, command.trim(), result.decision, result.matchedRule);
16839
+ } catch (err) {
16840
+ deps.writeStderr(`agent-shell: unexpected error: ${sanitizeForStderr(err)}\n`);
16841
+ deps.writeStdout(denyResponse("Internal error during policy evaluation"));
16842
+ }
16133
16843
  }
16134
16844
 
16135
- ;// CONCATENATED MODULE: ./src/policy-init.ts
16136
-
16137
-
16845
+ ;// CONCATENATED MODULE: ./src/record.ts
16846
+ // biome-ignore-all lint/style/useNamingConvention: telemetry schema uses snake_case field names
16138
16847
 
16139
16848
 
16140
16849
 
16141
- const DEFAULT_SAFE_COMMANDS = [
16142
- "git status *",
16143
- "git diff *",
16144
- "git log *",
16145
- "git show *",
16146
- "git branch --show-current",
16147
- "git branch --list *",
16148
- "git rev-parse *",
16149
- "pwd"
16150
- ];
16151
- const DEFAULT_DENY_RULES = [
16152
- "rm -rf *",
16153
- "sudo *"
16154
- ];
16155
- const POLICY_SUBPATH = ".github/hooks/agent-shell/policy.json";
16156
- const HOOKS_SUBPATH = ".github/hooks/agent-shell/hooks.json";
16157
- /**
16158
- * Extracts the script or task name from a command string, skipping any
16159
- * flags (tokens starting with `-`) that appear between the prefix and
16160
- * the actual name. For example, `npm run -s build` or `npm run --silent build`
16161
- * both return `build`. Splits on any whitespace to avoid empty tokens from
16162
- * multiple consecutive spaces.
16163
- */ function extractNameAfterPrefix(cmd, prefix) {
16164
- const tokens = cmd.slice(prefix.length).trim().split(/\s+/);
16165
- for (const token of tokens){
16166
- if (token.length > 0 && !token.startsWith("-")) {
16167
- return token;
16168
- }
16169
- }
16170
- return "";
16171
- }
16850
+ const record_TERMINAL_TOOLS = new Set([
16851
+ "bash",
16852
+ "zsh",
16853
+ "ash",
16854
+ "sh"
16855
+ ]);
16856
+ const MAX_COMMAND_BYTES = 4096;
16857
+ const record_HookInputSchema = schemas_object({
16858
+ toolName: schemas_string().max(1024),
16859
+ toolArgs: unknown().optional()
16860
+ });
16172
16861
  /**
16173
- * Generates a policy configuration from project scan results.
16174
- * Creates an allow list of commands discovered in the project,
16175
- * plus common safe defaults. Includes standard deny rules.
16862
+ * Extracts a command string from toolArgs for terminal tools.
16176
16863
  *
16177
- * Allow rules use exact match by default to prevent shell
16178
- * metacharacter bypass (e.g. `npm test && curl evil`).
16179
- * Wildcard `*` is only used for commands where subcommand
16180
- * arguments are inherently expected and the base command
16181
- * is genuinely read-only (e.g. `git status *`).
16182
- */ function generatePolicy(scanResult) {
16183
- const allowSet = new Set();
16184
- for (const cmd of DEFAULT_SAFE_COMMANDS){
16185
- allowSet.add(cmd);
16864
+ * Parsing chain (mirrors policy-check.ts but observation-only):
16865
+ * 1. Check if toolArgs is a string
16866
+ * 2. Parse that string as JSON
16867
+ * 3. Verify result is a non-null plain object
16868
+ * 4. Check for a `command` property
16869
+ * 5. Verify `command` is a string
16870
+ *
16871
+ * Returns the command string on success, or an empty string for any failure.
16872
+ */ function extractTerminalCommand(toolArgs) {
16873
+ // Step 1: toolArgs must be a string
16874
+ if (typeof toolArgs !== "string") {
16875
+ return "";
16186
16876
  }
16187
- for (const script of scanResult.scripts){
16188
- const name = script.name.trim();
16189
- if (name.length === 0) {
16190
- continue;
16191
- }
16192
- if (SHELL_METACHAR_PATTERN.test(name)) {
16193
- continue;
16194
- }
16195
- if (name === "test") {
16196
- allowSet.add("npm test");
16197
- } else {
16198
- allowSet.add(`npm run ${name}`);
16199
- }
16877
+ // Step 2: parse as JSON
16878
+ let parsedArgs;
16879
+ try {
16880
+ parsedArgs = JSON.parse(toolArgs);
16881
+ } catch {
16882
+ return "";
16200
16883
  }
16201
- for (const cmd of scanResult.workflowCommands){
16202
- if (cmd === "npm test" || cmd.startsWith("npm test ")) {
16203
- allowSet.add("npm test");
16204
- if (cmd !== "npm test" && !SHELL_METACHAR_PATTERN.test(cmd)) {
16205
- allowSet.add(cmd);
16206
- }
16207
- } else if (cmd.startsWith("npm run ")) {
16208
- const scriptName = extractNameAfterPrefix(cmd, "npm run ");
16209
- if (scriptName) {
16210
- allowSet.add(`npm run ${scriptName}`);
16211
- if (cmd !== `npm run ${scriptName}` && !SHELL_METACHAR_PATTERN.test(cmd)) {
16212
- allowSet.add(cmd);
16213
- }
16214
- } else if (!SHELL_METACHAR_PATTERN.test(cmd)) {
16215
- allowSet.add(cmd);
16216
- }
16217
- } else if (cmd === "npm ci" || cmd === "npm install") {
16218
- allowSet.add(cmd);
16219
- } else if (cmd.startsWith("npx ")) {
16220
- if (!SHELL_METACHAR_PATTERN.test(cmd)) {
16221
- allowSet.add(cmd);
16222
- }
16223
- } else if (cmd.startsWith("mise run ")) {
16224
- const taskName = extractNameAfterPrefix(cmd, "mise run ");
16225
- if (taskName) {
16226
- allowSet.add(`mise run ${taskName}`);
16227
- if (cmd !== `mise run ${taskName}` && !SHELL_METACHAR_PATTERN.test(cmd)) {
16228
- allowSet.add(cmd);
16229
- }
16230
- } else if (!SHELL_METACHAR_PATTERN.test(cmd)) {
16231
- allowSet.add(cmd);
16232
- }
16233
- } else {
16234
- if (!SHELL_METACHAR_PATTERN.test(cmd)) {
16235
- allowSet.add(cmd);
16236
- }
16237
- }
16884
+ // Step 3: must be a non-null plain object
16885
+ if (parsedArgs === null || typeof parsedArgs !== "object" || Array.isArray(parsedArgs)) {
16886
+ return "";
16238
16887
  }
16239
- for (const task of scanResult.miseTasks){
16240
- const taskName = task.name.trim();
16241
- if (taskName.length > 0 && !SHELL_METACHAR_PATTERN.test(taskName)) {
16242
- allowSet.add(`mise run ${taskName}`);
16243
- }
16888
+ // Step 4: must have `command` property (reject prototype pollution keys)
16889
+ const obj = parsedArgs;
16890
+ if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
16891
+ return "";
16244
16892
  }
16245
- if (scanResult.miseTasks.length > 0) {
16246
- allowSet.add("mise install");
16893
+ if (!Object.hasOwn(obj, "command")) {
16894
+ return "";
16247
16895
  }
16248
- const allow = [
16249
- ...allowSet
16250
- ].sort();
16251
- return {
16252
- allow,
16253
- deny: [
16254
- ...DEFAULT_DENY_RULES
16255
- ]
16256
- };
16257
- }
16258
- /**
16259
- * Generates the Copilot hooks.json configuration with agent-shell
16260
- * policy-check as a preToolUse hook.
16261
- */ function generateHooksConfig() {
16262
- return {
16263
- version: 1,
16264
- hooks: {
16265
- preToolUse: [
16266
- {
16267
- type: "command",
16268
- bash: "agent-shell policy-check",
16269
- timeoutSec: 30
16270
- }
16271
- ]
16272
- }
16273
- };
16274
- }
16275
- /**
16276
- * Writes a file atomically, skipping if the file already exists.
16277
- * Uses the `wx` (exclusive create) flag to avoid TOCTOU races
16278
- * between a check and write.
16279
- */ async function writeFileIfNotExists(filePath, parentDir, content, subpath, writeStdout) {
16280
- await (0,promises_namespaceObject.mkdir)(parentDir, {
16281
- recursive: true
16282
- });
16896
+ // Step 5: command must be a string
16897
+ if (typeof obj.command !== "string") {
16898
+ return "";
16899
+ }
16900
+ // Truncate to MAX_COMMAND_BYTES using byte-aware slicing (matching env-capture.ts pattern)
16901
+ if (Buffer.byteLength(obj.command, "utf-8") > MAX_COMMAND_BYTES) {
16902
+ // Codepoint-aware truncation: stop before exceeding the byte limit.
16903
+ // for...of iterates Unicode codepoints, so surrogate pairs are handled
16904
+ // correctly and we never truncate mid-sequence.
16905
+ let byteCount = 0;
16906
+ let truncated = "";
16907
+ for (const ch of obj.command){
16908
+ const charBytes = Buffer.byteLength(ch, "utf-8");
16909
+ if (byteCount + charBytes > MAX_COMMAND_BYTES) break;
16910
+ byteCount += charBytes;
16911
+ truncated += ch;
16912
+ }
16913
+ return truncated;
16914
+ }
16915
+ return obj.command;
16916
+ }
16917
+ async function handleRecord(deps) {
16918
+ let rawStdin;
16283
16919
  try {
16284
- await (0,promises_namespaceObject.writeFile)(filePath, content, {
16285
- flag: "wx"
16286
- });
16287
- writeStdout(`Created ${subpath}\n`);
16288
- } catch (error) {
16289
- if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
16290
- writeStdout(`Skipping ${subpath} — file already exists\n`);
16291
- } else {
16292
- throw error;
16293
- }
16920
+ rawStdin = await deps.readStdin();
16921
+ } catch (err) {
16922
+ deps.writeStderr(`agent-shell: failed to read stdin: ${sanitizeForStderr(err)}\n`);
16923
+ return false;
16294
16924
  }
16295
- }
16296
- /**
16297
- * Handles the `policy --init` command.
16298
- * Scans the project, generates a policy and hooks configuration,
16299
- * and writes them to disk. Optionally uses @github/copilot-sdk
16300
- * for AI-enhanced analysis when available.
16301
- */ async function handlePolicyInit(deps) {
16302
- const repoRoot = deps.getRepositoryRoot();
16303
- deps.writeStdout("Scanning project...\n");
16304
- const scanResult = await scanProject(repoRoot);
16305
- deps.writeStdout(`Discovered: ${scanResult.scripts.length} npm script(s), ` + `${scanResult.workflowCommands.length} workflow command(s), ` + `${scanResult.miseTasks.length} mise task(s), ` + `${scanResult.languages.length} language(s)\n`);
16306
- const policy = generatePolicy(scanResult);
16307
- const enhanced = await enhanceWithCopilot(scanResult, repoRoot, deps.writeStderr, deps.model);
16308
- if (enhanced !== null) {
16309
- deps.writeStdout("Enhanced with Copilot analysis\n");
16310
- if (enhanced.additionalAllowRules.length > 0) {
16311
- deps.writeStdout("\nSuggested additional allow rules from Copilot (not auto-applied):\n");
16312
- for (const rule of enhanced.additionalAllowRules){
16313
- if (isSafeCommand(rule)) {
16314
- deps.writeStdout(` - ${sanitizeOutput(rule)}\n`);
16315
- } else {
16316
- deps.writeStdout(` - [UNSAFE, skipped] ${sanitizeOutput(rule)}\n`);
16317
- }
16318
- }
16319
- }
16320
- if (enhanced.suggestions.length > 0) {
16321
- deps.writeStdout("\nSuggestions from Copilot:\n");
16322
- for (const suggestion of enhanced.suggestions){
16323
- deps.writeStdout(` - ${sanitizeOutput(suggestion)}\n`);
16324
- }
16325
- }
16925
+ let input;
16926
+ try {
16927
+ input = JSON.parse(rawStdin);
16928
+ } catch {
16929
+ deps.writeStderr("agent-shell: failed to parse stdin as JSON\n");
16930
+ return false;
16326
16931
  }
16327
- const hooksConfig = generateHooksConfig();
16328
- const policyPath = (0,external_node_path_namespaceObject.join)(repoRoot, POLICY_SUBPATH);
16329
- const hooksPath = (0,external_node_path_namespaceObject.join)(repoRoot, HOOKS_SUBPATH);
16330
- const policyContent = `${JSON.stringify(policy, null, 2)}\n`;
16331
- const hooksContent = `${JSON.stringify(hooksConfig, null, 2)}\n`;
16332
- await writeFileIfNotExists(policyPath, (0,external_node_path_namespaceObject.join)(repoRoot, ".github", "hooks", "agent-shell"), policyContent, POLICY_SUBPATH, deps.writeStdout);
16333
- await writeFileIfNotExists(hooksPath, (0,external_node_path_namespaceObject.join)(repoRoot, ".github", "hooks", "agent-shell"), hooksContent, HOOKS_SUBPATH, deps.writeStdout);
16334
- deps.writeStdout("\n--- Proposed Policy ---\n");
16335
- deps.writeStdout(`${sanitizeOutput(JSON.stringify(policy, null, 2))}\n`);
16336
- deps.writeStdout("\n--- Hook Configuration ---\n");
16337
- deps.writeStdout(`${sanitizeOutput(JSON.stringify(hooksConfig, null, 2))}\n`);
16932
+ const hookResult = record_HookInputSchema.safeParse(input);
16933
+ if (!hookResult.success) {
16934
+ deps.writeStderr("agent-shell: missing or invalid toolName field, skipping telemetry\n");
16935
+ return false;
16936
+ }
16937
+ const { toolName, toolArgs } = hookResult.data;
16938
+ const command = record_TERMINAL_TOOLS.has(toolName) ? extractTerminalCommand(toolArgs) : "";
16939
+ try {
16940
+ const repoRoot = deps.getRepositoryRoot();
16941
+ await emitToolUseEvent({
16942
+ tool_name: toolName,
16943
+ command,
16944
+ env: deps.env,
16945
+ projectRoot: repoRoot
16946
+ }, deps.telemetryDeps);
16947
+ } catch (err) {
16948
+ deps.writeStderr(`agent-shell: telemetry write error: ${sanitizeForStderr(err)}\n`);
16949
+ return false;
16950
+ }
16951
+ return true;
16338
16952
  }
16339
16953
 
16340
16954
  ;// CONCATENATED MODULE: ./src/shim.ts
@@ -16428,12 +17042,18 @@ async function runShim(options) {
16428
17042
 
16429
17043
 
16430
17044
 
17045
+
17046
+
17047
+
17048
+
16431
17049
  const VERSION = "0.1.0";
16432
17050
  const USAGE = `Usage: agent-shell -c <command>
17051
+ agent-shell init [--flight-recorder] [--policy] [--no-flight-recorder] [--no-policy]
17052
+ agent-shell record
16433
17053
  agent-shell policy-check
16434
17054
  agent-shell policy --init [--model=<model>]
16435
- agent-shell --version
16436
17055
  agent-shell log
17056
+ agent-shell --version
16437
17057
 
16438
17058
  Environment:
16439
17059
  AGENTSHELL_PASSTHROUGH=1 Bypass instrumentation
@@ -16496,6 +17116,79 @@ async function main() {
16496
17116
  }
16497
17117
  return;
16498
17118
  }
17119
+ case "record":
17120
+ {
17121
+ const getRepositoryRoot = createGetRepositoryRoot(undefined, process.env);
17122
+ try {
17123
+ await handleRecord({
17124
+ readStdin: ()=>readStdin(),
17125
+ writeStderr: (data)=>process.stderr.write(data),
17126
+ env: process.env,
17127
+ telemetryDeps: createDefaultDeps(),
17128
+ getRepositoryRoot
17129
+ });
17130
+ } catch (err) {
17131
+ // Log unexpected exceptions but don't change exit code
17132
+ process.stderr.write(`agent-shell: record error: ${sanitizeForStderr(err)}\n`);
17133
+ }
17134
+ // Observation-only: always exit 0 regardless of parse/emit errors.
17135
+ // postToolUse hooks must not block agent operations and the agent
17136
+ // ignores the exit code anyway.
17137
+ process.exitCode = 0;
17138
+ return;
17139
+ }
17140
+ case "init":
17141
+ {
17142
+ try {
17143
+ if (mode.unknownArgs.length > 0) {
17144
+ process.stderr.write(`agent-shell: unknown flag(s): ${mode.unknownArgs.map(sanitizeForStderr).join(", ")}\n`);
17145
+ process.exitCode = 1;
17146
+ return;
17147
+ }
17148
+ const getRepositoryRoot = createGetRepositoryRoot(undefined, process.env);
17149
+ const isTty = Boolean(process.stdin.isTTY);
17150
+ const prompt = isTty ? async (message)=>{
17151
+ const rl = (0,external_node_readline_namespaceObject.createInterface)({
17152
+ input: process.stdin,
17153
+ output: process.stdout
17154
+ });
17155
+ try {
17156
+ return await new Promise((resolvePrompt)=>{
17157
+ rl.question(`${message} (Y/n) `, (answer)=>{
17158
+ const trimmed = answer.trim().toLowerCase();
17159
+ resolvePrompt(trimmed === "" || trimmed === "y" || trimmed === "yes");
17160
+ });
17161
+ });
17162
+ } finally{
17163
+ rl.close();
17164
+ }
17165
+ } : undefined;
17166
+ const ok = await handleInit({
17167
+ flightRecorder: mode.flightRecorder,
17168
+ policy: mode.policy,
17169
+ noFlightRecorder: mode.noFlightRecorder,
17170
+ noPolicy: mode.noPolicy
17171
+ }, {
17172
+ getRepositoryRoot,
17173
+ writeStdout: (data)=>process.stdout.write(data),
17174
+ writeStderr: (data)=>process.stderr.write(data),
17175
+ readFile: (path, encoding)=>(0,promises_namespaceObject.readFile)(path, encoding),
17176
+ writeFile: (path, content, options)=>(0,promises_namespaceObject.writeFile)(path, content, options),
17177
+ rename: (oldPath, newPath)=>(0,promises_namespaceObject.rename)(oldPath, newPath),
17178
+ unlink: (path)=>(0,promises_namespaceObject.unlink)(path),
17179
+ mkdir: (path, opts)=>(0,promises_namespaceObject.mkdir)(path, opts).then(()=>undefined),
17180
+ realpath: (path)=>(0,promises_namespaceObject.realpath)(path),
17181
+ scanProject: (dir)=>scanProject(dir),
17182
+ isTty,
17183
+ prompt
17184
+ });
17185
+ process.exitCode = ok ? 0 : 1;
17186
+ } catch (err) {
17187
+ process.stderr.write(`agent-shell: init error: ${sanitizeForStderr(err)}\n`);
17188
+ process.exitCode = 1;
17189
+ }
17190
+ return;
17191
+ }
16499
17192
  case "passthrough":
16500
17193
  {
16501
17194
  const result = (0,external_node_child_process_namespaceObject.spawnSync)("/bin/sh", mode.args, {