@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.
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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" ==
|
|
139
|
-
log_warn "Detected execution inside
|
|
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 "
|
|
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="$
|
|
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
|
package/src/commands/sandbox.ts
CHANGED
|
@@ -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 =
|
|
251
|
+
const sandboxDir = sandboxTree()
|
|
228
252
|
if (!existsSync(sandboxDir)) {
|
|
229
253
|
logError(`No sandbox at ${sandboxDir}. Provision one with aitk sandbox.`)
|
|
230
254
|
outro()
|
package/src/sandbox/expect.ts
CHANGED
|
@@ -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 {
|