@erclx/aitk 0.19.0 → 0.20.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.19.0",
4
+ "version": "0.20.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.19.0",
4
+ "version": "0.20.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # The sandbox tree lives outside the toolkit worktree. `scripts/sandbox/run.sh`
4
+ # sets cwd to it for `claude -p`, and every `CLAUDE.md` between that cwd and the
5
+ # filesystem root loads into the session. Under the repository the toolkit's own
6
+ # instructions join that chain beside the seeded copy the scenario installed,
7
+ # both carry the rule sending shared session scratch to the main worktree root,
8
+ # and nothing decides which root wins. A session picking the toolkit writes its
9
+ # output where no manifest reads it, so the run reports success while the verdict
10
+ # reports no writes at all. `scripts/eval/run.sh` keeps its fixture outside the
11
+ # repository for the same reason.
12
+ #
13
+ # Twin of `SANDBOX_DIR` in `src/commands/sandbox.ts`. The exec boundary rules out
14
+ # a shared constant, so a change to the default lands on both sides.
15
+ resolve_sandbox_dir() {
16
+ if [ -n "${AITK_SANDBOX_DIR:-}" ]; then
17
+ printf '%s\n' "$AITK_SANDBOX_DIR"
18
+ return 0
19
+ fi
20
+
21
+ printf '%s/aitk/sandbox\n' "${XDG_STATE_HOME:-$HOME/.local/state}"
22
+ }
23
+
24
+ # Collapses repeated separators and strips every trailing one, leaving a bare
25
+ # root as `/`. Every comparison below is a string test, so `//` and `$HOME//`
26
+ # would otherwise read as paths no rule names.
27
+ normalize_sandbox_path() {
28
+ local path="$1"
29
+
30
+ while [ "$path" != "${path//\/\//\/}" ]; do
31
+ path="${path//\/\//\/}"
32
+ done
33
+
34
+ while [ "${#path}" -gt 1 ] && [ "${path%/}" != "$path" ]; do
35
+ path="${path%/}"
36
+ done
37
+
38
+ printf '%s' "$path"
39
+ }
40
+
41
+ # Whether `candidate` is `target` or a directory containing it. Removing the
42
+ # former removes the latter, which is what makes an ancestor as dangerous as an
43
+ # exact match.
44
+ is_at_or_above() {
45
+ local candidate="$1"
46
+ local target="$2"
47
+
48
+ [ "$candidate" = "$target" ] || [ "${target#"$candidate"/}" != "$target" ]
49
+ }
50
+
51
+ # Prints the reason a location is unusable and returns non-zero, so the caller
52
+ # reports one message naming the path rather than a bare refusal.
53
+ #
54
+ # Provisioning runs `rm -rf` on this path at three sites before staging, so the
55
+ # test is an allowlist rather than a list of paths to refuse. A blocklist has to
56
+ # name every system directory to be right once and stays wrong as soon as one is
57
+ # missed, while requiring a strict descendant of the home directory or of the
58
+ # temp root admits the default and every reasonable override and refuses `/`,
59
+ # `/usr`, `/etc`, and `$HOME` itself without naming any of them.
60
+ #
61
+ # The repository test is separate and runs both ways. A path under the worktree
62
+ # restores the ancestor chain the relocation removed, and a path above it is one
63
+ # `rm -rf` away from deleting the repository. It resolves the main worktree root
64
+ # rather than trusting `$PROJECT_ROOT`, since a linked worktree is itself inside
65
+ # that root.
66
+ assert_sandbox_dir_safe() {
67
+ local raw="$1"
68
+ local root="${2:-${PROJECT_ROOT:-$PWD}}"
69
+
70
+ if [ -z "$raw" ] || [ "${raw#/}" = "$raw" ]; then
71
+ printf 'AITK_SANDBOX_DIR must be an absolute path, got: %s\n' "${raw:-<empty>}"
72
+ return 1
73
+ fi
74
+
75
+ local dir home temp
76
+ dir="$(normalize_sandbox_path "$raw")"
77
+ home="$(normalize_sandbox_path "${HOME:-/root}")"
78
+ temp="$(normalize_sandbox_path "${TMPDIR:-/tmp}")"
79
+
80
+ if ! is_at_or_above "$home" "$dir" && ! is_at_or_above "$temp" "$dir"; then
81
+ printf 'Refusing %s as the sandbox. Provisioning removes the tree first, so the path has to sit under %s or %s.\n' "$raw" "$home" "$temp"
82
+ return 1
83
+ fi
84
+
85
+ if [ "$dir" = "$home" ] || [ "$dir" = "$temp" ]; then
86
+ printf 'Refusing %s as the sandbox. Provisioning removes the tree first, so the path has to sit under %s rather than be it.\n' "$raw" "$dir"
87
+ return 1
88
+ fi
89
+
90
+ local main_root
91
+ main_root="$(git -C "$root" worktree list --porcelain 2>/dev/null |
92
+ grep -m 1 '^worktree ' | cut -d' ' -f2-)"
93
+ main_root="$(normalize_sandbox_path "${main_root:-$root}")"
94
+
95
+ if is_at_or_above "$dir" "$main_root"; then
96
+ printf 'Refusing %s as the sandbox. Provisioning removes the tree first, and that path contains %s.\n' "$raw" "$main_root"
97
+ return 1
98
+ fi
99
+
100
+ case "$dir" in
101
+ "$main_root"/*)
102
+ printf 'Sandbox at %s sits inside %s, which puts the toolkit CLAUDE.md back on the session ancestor chain. Point AITK_SANDBOX_DIR outside the repository.\n' "$raw" "$main_root"
103
+ return 1
104
+ ;;
105
+ esac
106
+
107
+ return 0
108
+ }
package/scripts/lib/ui.sh CHANGED
@@ -6,6 +6,12 @@ if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ]; then
6
6
  exit 1
7
7
  fi
8
8
 
9
+ # Resolved from this file's own location rather than `$PROJECT_ROOT`, because
10
+ # `require_project_root` is what several scripts call before anything has
11
+ # established a root.
12
+ # shellcheck source=/dev/null
13
+ source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/sandbox-path.sh"
14
+
9
15
  GREEN='\033[0;32m'
10
16
  RED='\033[0;31m'
11
17
  YELLOW='\033[0;33m'
@@ -46,7 +52,9 @@ guard_root() {
46
52
  }
47
53
 
48
54
  require_project_root() {
49
- if [[ "$PWD" == *".sandbox"* ]]; then
55
+ local sandbox
56
+ sandbox="$(resolve_sandbox_dir)"
57
+ if [[ "$PWD" == "$sandbox" || "$PWD" == "$sandbox"/* ]]; then
50
58
  echo -e "${GREY}┌${NC}" >&2
51
59
  log_error "Execution restricted: Command cannot be run from inside the sandbox environment."
52
60
  fi
@@ -8,6 +8,7 @@ export PROJECT_ROOT
8
8
 
9
9
  source "$PROJECT_ROOT/scripts/config.sh"
10
10
  source "$PROJECT_ROOT/scripts/lib/ui.sh"
11
+ source "$PROJECT_ROOT/scripts/lib/sandbox-path.sh"
11
12
  source "$PROJECT_ROOT/scripts/lib/sandbox-git.sh"
12
13
  source "$PROJECT_ROOT/scripts/lib/sandbox-fixtures.sh"
13
14
 
@@ -135,8 +136,8 @@ validate_environment() {
135
136
  log_error "Sandbox directory not found at: $SANDBOX_DIR"
136
137
  fi
137
138
 
138
- if [[ "$PWD" == *".sandbox"* ]]; then
139
- log_warn "Detected execution inside .sandbox. Switching to project root..."
139
+ if [[ "$PWD" == "$SANDBOX" || "$PWD" == "$SANDBOX"/* ]]; then
140
+ log_warn "Detected execution inside the sandbox. Switching to project root..."
140
141
  cd "$PROJECT_ROOT" || log_error "Failed to switch to project root."
141
142
  fi
142
143
  }
@@ -324,7 +325,7 @@ finalize_sandbox_run() {
324
325
  cmd_clean() {
325
326
  log_step "Removing sandbox"
326
327
  rm -rf "$SANDBOX"
327
- log_rem ".sandbox/"
328
+ log_rem "$SANDBOX"
328
329
  trap - EXIT
329
330
  close_timeline
330
331
  echo "" >&2
@@ -422,9 +423,14 @@ main() {
422
423
  log_error "Context error: you must run this command from inside the toolkit repository."
423
424
  fi
424
425
 
425
- SANDBOX="$PROJECT_ROOT/.sandbox"
426
+ SANDBOX="$(resolve_sandbox_dir)"
426
427
  SANDBOX_DIR="$PROJECT_ROOT/scripts/sandbox"
427
428
 
429
+ local unsafe
430
+ if ! unsafe="$(assert_sandbox_dir_safe "$SANDBOX" "$PROJECT_ROOT")"; then
431
+ log_error "$unsafe"
432
+ fi
433
+
428
434
  if [[ "$1" == "reset" ]]; then
429
435
  reset_sandbox
430
436
  exit 0
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
2
3
  import { join } from 'node:path'
3
4
  import type { Command } from 'commander'
4
5
  import { PROJECT_ROOT, execScript } from '@/exec'
@@ -28,6 +29,29 @@ import {
28
29
 
29
30
  const SANDBOX_DIR = join(PROJECT_ROOT, 'scripts', 'sandbox')
30
31
 
32
+ /**
33
+ * The provisioned tree, as opposed to `SANDBOX_DIR` above, which holds the
34
+ * scenario scripts. It sits outside the toolkit worktree so the toolkit's own
35
+ * `CLAUDE.md` stays off the ancestor chain of the session `run.sh` spawns with
36
+ * cwd here.
37
+ *
38
+ * Twin of `resolve_sandbox_dir` in `scripts/lib/sandbox-path.sh`. The exec
39
+ * boundary rules out a shared constant, so a change to the default lands on both
40
+ * sides.
41
+ */
42
+ function sandboxTree(): string {
43
+ const override = process.env.AITK_SANDBOX_DIR
44
+ if (override !== undefined && override !== '') return override
45
+
46
+ const state = process.env.XDG_STATE_HOME
47
+ const base =
48
+ state !== undefined && state !== ''
49
+ ? state
50
+ : join(homedir(), '.local', 'state')
51
+
52
+ return join(base, 'aitk', 'sandbox')
53
+ }
54
+
31
55
  /**
32
56
  * Holds fixture content for scenarios rather than scenarios of its own.
33
57
  * Twin of the `-not -name fixtures` filter in `scripts/manage-sandbox.sh`.
@@ -224,7 +248,7 @@ function runCheck(
224
248
  // A sandbox that was never provisioned fails every path assertion, reading as a
225
249
  // skill that did nothing rather than a caller that ran check too early. The
226
250
  // whole point of a verdict is that it means what it says.
227
- const sandboxDir = join(PROJECT_ROOT, '.sandbox')
251
+ const sandboxDir = sandboxTree()
228
252
  if (!existsSync(sandboxDir)) {
229
253
  logError(`No sandbox at ${sandboxDir}. Provision one with aitk sandbox.`)
230
254
  outro()
@@ -141,9 +141,33 @@ function contentArray(value: unknown): ContentAssertion[] {
141
141
  if (typeof entry !== 'object' || entry === null) continue
142
142
 
143
143
  const record = entry as Record<string, unknown>
144
+
145
+ // Dropping a half-written entry rather than throwing is deliberate, and it
146
+ // reads as the opposite of the stray-key check below. An entry missing its
147
+ // path or pattern declares no assertion to lose, and `checkExpectation`
148
+ // fails an arm whose surviving declaration asserts nothing, so the vacuous
149
+ // pass is already closed one level up. A stray key is the reverse: the entry
150
+ // is well-formed and the declaration around it silently lost a key it
151
+ // appears to carry, which nothing downstream can see.
144
152
  if (typeof record.path !== 'string' || record.path === '') continue
145
153
  if (typeof record.pattern !== 'string' || record.pattern === '') continue
146
154
 
155
+ // A bare key written below a `[[content]]` header belongs to that table in
156
+ // TOML, not to the document, so a declaration listing `manual` or
157
+ // `max_turns` after its content blocks parses clean and silently asserts
158
+ // neither. The `claude/ui-test` arm shipped that way: a turn ceiling that
159
+ // never ran and five manual entries that never reached the unchecked count,
160
+ // while `aitk sandbox coverage` counted the arm as armed. Nothing at the
161
+ // top level can see the difference, so the check belongs here.
162
+ const stray = Object.keys(record).filter(
163
+ (key) => key !== 'path' && key !== 'pattern',
164
+ )
165
+ if (stray.length > 0) {
166
+ throw new Error(
167
+ `content entry for ${record.path} carries ${stray.join(', ')}. Move top-level keys above the first [[content]] block.`,
168
+ )
169
+ }
170
+
147
171
  assertions.push({ path: record.path, pattern: record.pattern })
148
172
  }
149
173
 
@@ -257,6 +281,20 @@ function checkWriteScope(
257
281
  }
258
282
  }
259
283
 
284
+ // A scope produces one result per write, so a run that wrote nothing produces
285
+ // none, and without this the declaration vanishes from the verdict entirely:
286
+ // no result, no skipped entry, and no contribution to the unchecked count that
287
+ // exists to surface exactly this. The `undefined` branch above cannot stand in,
288
+ // since `run.sh` always passes `--writes` and `readWrites` returns `[]` for an
289
+ // empty file. An arm whose output escaped the snapshot reads as a clean run,
290
+ // which is the vacuous pass the harness exists to remove.
291
+ if (writes.length === 0) {
292
+ return {
293
+ results: [],
294
+ skipped: ['write scope: the run wrote nothing, so no path was checked'],
295
+ }
296
+ }
297
+
260
298
  const globs = expectation.writeScope.map((glob) => new Bun.Glob(glob))
261
299
 
262
300
  return {